@astrale-os/kernel-client 0.6.0-beta.72 → 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/auth/credential.d.ts +1 -0
- package/dist/auth/credential.js +2 -2
- 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/schema/introspection.js +4 -0
- package/dist/session/admission.d.ts +3 -15
- package/dist/session/admission.js +16 -74
- package/dist/session/credential/index.d.ts +1 -1
- package/dist/session/credential/index.js +1 -1
- package/dist/session/credential/provider.d.ts +5 -0
- package/dist/session/credential/provider.js +8 -1
- package/dist/session/exchange/domain.d.ts +27 -0
- package/dist/session/exchange/domain.js +63 -0
- package/dist/session/exchange/exchange.d.ts +54 -0
- package/dist/session/exchange/exchange.js +58 -0
- package/dist/session/exchange/index.d.ts +5 -0
- package/dist/session/exchange/index.js +5 -0
- package/dist/session/exchange/issuer.d.ts +18 -0
- package/dist/session/exchange/issuer.js +132 -0
- package/dist/session/exchange/limits.d.ts +10 -0
- package/dist/session/exchange/limits.js +10 -0
- package/dist/session/exchange/options.d.ts +29 -0
- package/dist/session/exchange/options.js +57 -0
- package/dist/session/exchange/presentation.d.ts +18 -0
- package/dist/session/exchange/presentation.js +73 -0
- package/dist/session/exchange/source.d.ts +32 -0
- package/dist/session/exchange/source.js +78 -0
- package/dist/session/execution.d.ts +65 -0
- package/dist/session/execution.js +35 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/reference.d.ts +7 -1
- package/dist/session/routing/auth.d.ts +10 -0
- package/dist/session/routing/auth.js +8 -1
- package/dist/session/routing/dispatch.d.ts +2 -1
- package/dist/session/routing/dispatch.js +5 -2
- package/dist/session/routing/index.d.ts +1 -1
- package/dist/session/session.d.ts +16 -0
- package/dist/session/session.js +24 -4
- package/dist/session/session.typecheck.js +32 -1
- package/dist/transport/http/transport.js +0 -1
- package/package.json +5 -5
|
@@ -21,6 +21,7 @@ export interface DelegateOptions {
|
|
|
21
21
|
}
|
|
22
22
|
export declare const IDENTITY_IMPERSONATE_PATH: NodePath;
|
|
23
23
|
export declare const IDENTITY_MINT_PATH: NodePath;
|
|
24
|
+
export declare const IDENTITY_DELEGATE_PATH: NodePath;
|
|
24
25
|
export declare function credentialApi(call: AuthCall): Readonly<{
|
|
25
26
|
impersonate(input: ImpersonateOptions, options?: RequestOptions): Promise<MintedCredential>;
|
|
26
27
|
mint(input: MintOptions, options?: RequestOptions): Promise<MintedCredential>;
|
package/dist/auth/credential.js
CHANGED
|
@@ -3,6 +3,7 @@ import { identity } from '@astrale-os/kernel-core/schema/kernel/syscalls/referen
|
|
|
3
3
|
const identityOwner = Path.project(identity.whoami.owner);
|
|
4
4
|
export const IDENTITY_IMPERSONATE_PATH = Path.staticMethod(identityOwner, identity.impersonate.name).raw;
|
|
5
5
|
export const IDENTITY_MINT_PATH = Path.staticMethod(identityOwner, identity.mint.name).raw;
|
|
6
|
+
export const IDENTITY_DELEGATE_PATH = Path.staticMethod(identityOwner, identity.delegate.name).raw;
|
|
6
7
|
export function credentialApi(call) {
|
|
7
8
|
return Object.freeze({
|
|
8
9
|
async impersonate(input, options) {
|
|
@@ -12,8 +13,7 @@ export function credentialApi(call) {
|
|
|
12
13
|
return credential(await call(IDENTITY_MINT_PATH, input, options));
|
|
13
14
|
},
|
|
14
15
|
async delegate(principal, input, options) {
|
|
15
|
-
|
|
16
|
-
return credential(await call(target, { ...input, principal }, options));
|
|
16
|
+
return credential(await call(IDENTITY_DELEGATE_PATH, { ...input, principal }, options));
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
19
|
}
|
|
@@ -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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Path } from '@astrale-os/kernel-core/path';
|
|
2
2
|
import { introspect as introspectRef } from '@astrale-os/kernel-core/schema/kernel/syscalls/references';
|
|
3
|
+
import { loadMsgpack } from '@astrale-os/kernel-protocol/codecs';
|
|
3
4
|
import * as schema from '@astrale-os/kernel-protocol/schema';
|
|
4
5
|
import { call } from '../client/index.js';
|
|
5
6
|
const INTROSPECT_PATH = Path.project(introspectRef).raw;
|
|
6
7
|
export async function introspect(invoke, limits, request, options) {
|
|
8
|
+
// The evidence is MessagePack behind a lazy import: fetch it with the request, not after it.
|
|
9
|
+
// A failed preload is surfaced by decode, which imports the same modules again.
|
|
10
|
+
void loadMsgpack().catch(() => undefined);
|
|
7
11
|
const result = await invoke(call(INTROSPECT_PATH, request), options);
|
|
8
12
|
if (result.kind !== 'binary') {
|
|
9
13
|
throw new TypeError('Schema introspection returned a non-binary result.');
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import type { invocation } from '@astrale-os/kernel-protocol';
|
|
2
2
|
import type { AdmissionOptions, FunctionAdmission } from '../auth/index.js';
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
5
|
-
import type { Fetch } from '../transport/http/index.js';
|
|
6
|
-
import type { SessionOperation, SessionPolicy } from './context/index.js';
|
|
7
|
-
import type { SessionAuth } from './routing/index.js';
|
|
8
|
-
export interface FunctionAdmissionDependencies {
|
|
9
|
-
readonly kernel: string;
|
|
10
|
-
readonly auth?: SessionAuth;
|
|
11
|
-
readonly source: () => Client;
|
|
12
|
-
readonly fetch: Fetch;
|
|
13
|
-
readonly policy: Readonly<SessionPolicy>;
|
|
14
|
-
readonly limits: SchemaOptions;
|
|
15
|
-
readonly maximumResponseBytes: number;
|
|
16
|
-
}
|
|
3
|
+
import type { SessionOperation } from './context/index.js';
|
|
4
|
+
import type { ExchangeDependencies } from './exchange/index.js';
|
|
17
5
|
/** Check a remote Call through the existing Domain exchange and Kernel admission protocols. */
|
|
18
|
-
export declare function admitFunction(dependencies:
|
|
6
|
+
export declare function admitFunction(dependencies: ExchangeDependencies, call: invocation.Call, options: AdmissionOptions | undefined, operation: SessionOperation): Promise<FunctionAdmission>;
|
|
@@ -4,13 +4,9 @@ import { Path } from '@astrale-os/kernel-core/path';
|
|
|
4
4
|
import { admitOutput } from '@astrale-os/kernel-core/schema/kernel/syscalls/contracts';
|
|
5
5
|
import { function as functions } from '@astrale-os/kernel-core/schema/kernel/syscalls/references';
|
|
6
6
|
import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
|
|
7
|
-
import { codecs, issuer } from '@astrale-os/kernel-protocol';
|
|
8
|
-
import { credentialApi } from '../auth/credential.js';
|
|
9
|
-
import { identityApi } from '../auth/identity.js';
|
|
10
7
|
import { ClientError } from '../errors/index.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { boundedResponse, requireExactResponse } from './discovery/index.js';
|
|
8
|
+
import { runSessionOperation, sessionTransportOptions } from './context/index.js';
|
|
9
|
+
import { callableOrigin, exchangeDomainCredential, installedIssuer, isAuthority, SELF_ATTENUATION, } from './exchange/index.js';
|
|
14
10
|
import { matchesCallable } from './reference.js';
|
|
15
11
|
import { requireResultKind, resolveAuth } from './routing/index.js';
|
|
16
12
|
const ADMIT = Path.staticMethod(Path.project(functions.admit.owner), functions.admit.name);
|
|
@@ -18,47 +14,30 @@ const ADMIT = Path.staticMethod(Path.project(functions.admit.owner), functions.a
|
|
|
18
14
|
export async function admitFunction(dependencies, call, options, operation) {
|
|
19
15
|
const target = Path.parse(targetPath(call.target));
|
|
20
16
|
const origin = callableOrigin(target);
|
|
17
|
+
if (origin === undefined) {
|
|
18
|
+
throw new ClientError('Function admission requires a Domain-qualified callable target.');
|
|
19
|
+
}
|
|
21
20
|
const attenuation = options?.attenuation === undefined
|
|
22
21
|
? undefined
|
|
23
22
|
: grant.acceptUnresolved({ v: 1, expr: options.attenuation }).expr;
|
|
24
23
|
const effective = await resolveAuth(dependencies.auth, call, operation);
|
|
25
|
-
if (effective
|
|
24
|
+
if (!isAuthority(effective)) {
|
|
26
25
|
throw new ClientError('Function admission requires an authenticated session.');
|
|
27
26
|
}
|
|
28
27
|
// Resolve once for the intended Call, then retain that exact caller across every helper syscall.
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const dispatch = (input) => runSessionOperation(operation, () => caller.dispatch(input, {
|
|
32
|
-
...sessionTransportOptions(operation),
|
|
33
|
-
delegate: effective.delegate,
|
|
34
|
-
}));
|
|
35
|
-
const callIdentity = async (target, input) => requireResultKind('value', await dispatch({ target, input })).value;
|
|
36
|
-
const identity = identityApi(callIdentity);
|
|
37
|
-
const credentials = credentialApi(callIdentity);
|
|
38
|
-
const schema = createSchema(dispatch, dependencies.limits);
|
|
39
|
-
const [domain, self] = await Promise.all([schema.inspect(origin), identity.whoami()]);
|
|
40
|
-
const remote = domain.publication;
|
|
41
|
-
if (remote === null || remote.identity.issuer === dependencies.kernel) {
|
|
28
|
+
const domain = await installedIssuer(dependencies, origin, effective, operation);
|
|
29
|
+
if (domain === undefined) {
|
|
42
30
|
throw new ClientError('Function admission through token exchange requires a remote Domain.');
|
|
43
31
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
if (endpoint === undefined) {
|
|
47
|
-
throw new ClientError('The installed Domain does not advertise token exchange.');
|
|
48
|
-
}
|
|
49
|
-
allowEndpoint(endpoint, dependencies.policy);
|
|
50
|
-
const credential = await credentials.delegate(self.id, {
|
|
51
|
-
audience: remote.identity.issuer,
|
|
32
|
+
const exchanged = await exchangeDomainCredential(dependencies, effective, {
|
|
33
|
+
issuer: domain,
|
|
52
34
|
ttlSeconds: effective.delegate.ttlSeconds,
|
|
53
|
-
attenuation: attenuation ?? effective.delegate.attenuation ??
|
|
54
|
-
});
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const result = await runSessionOperation(operation, () => client
|
|
60
|
-
.as(exchanged.token)
|
|
61
|
-
.dispatch({ target: ADMIT, input: { call, credential } }, { ...sessionTransportOptions(operation), delegate: effective.delegate }));
|
|
35
|
+
attenuation: attenuation ?? effective.delegate.attenuation ?? SELF_ATTENUATION,
|
|
36
|
+
}, operation);
|
|
37
|
+
const result = await runSessionOperation(operation, () => dependencies
|
|
38
|
+
.source()
|
|
39
|
+
.as(exchanged.credential)
|
|
40
|
+
.dispatch({ target: ADMIT, input: { call, credential: exchanged.delegation } }, { ...sessionTransportOptions(operation), delegate: effective.delegate }));
|
|
62
41
|
const output = admitOutput.parse(requireResultKind('value', result).value);
|
|
63
42
|
const callable = Key(output.callable);
|
|
64
43
|
if (!matchesCallable(target, callable)) {
|
|
@@ -78,40 +57,3 @@ export async function admitFunction(dependencies, call, options, operation) {
|
|
|
78
57
|
}
|
|
79
58
|
return Object.freeze({ callable, ...(receiver === undefined ? {} : { receiver: receiver.raw }) });
|
|
80
59
|
}
|
|
81
|
-
function callableOrigin(path) {
|
|
82
|
-
const last = path.ast.steps.at(-1);
|
|
83
|
-
if (last?.kind === 'method' && last.dispatch === 'instance')
|
|
84
|
-
return Key.origin(last.class);
|
|
85
|
-
if (path.ast.anchor.kind === 'domain')
|
|
86
|
-
return path.ast.anchor.origin;
|
|
87
|
-
throw new ClientError('Function admission requires a Domain-qualified callable target.');
|
|
88
|
-
}
|
|
89
|
-
async function fetchJson(dependencies, url, operation, credential) {
|
|
90
|
-
allowEndpoint(url, dependencies.policy);
|
|
91
|
-
return runSessionOperation(operation, async (signal) => {
|
|
92
|
-
const init = {
|
|
93
|
-
method: credential === undefined ? 'GET' : 'POST',
|
|
94
|
-
headers: {
|
|
95
|
-
accept: 'application/json',
|
|
96
|
-
...(credential === undefined ? {} : { authorization: `Bearer ${credential}` }),
|
|
97
|
-
},
|
|
98
|
-
credentials: 'omit',
|
|
99
|
-
cache: 'no-store',
|
|
100
|
-
referrerPolicy: 'no-referrer',
|
|
101
|
-
redirect: 'manual',
|
|
102
|
-
signal,
|
|
103
|
-
};
|
|
104
|
-
const response = await dependencies.fetch(url, init);
|
|
105
|
-
try {
|
|
106
|
-
requireExactResponse(response, url, 'Domain token exchange');
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
throw new ClientError(`Domain ${credential === undefined ? 'discovery' : 'token exchange'} returned HTTP ${response.status}.`);
|
|
109
|
-
}
|
|
110
|
-
return codecs.json.decode(await boundedResponse(response, dependencies.maximumResponseBytes, signal));
|
|
111
|
-
}
|
|
112
|
-
finally {
|
|
113
|
-
// Also release unread bodies rejected by URL, status, or declared-size validation.
|
|
114
|
-
void response.body?.cancel().catch(() => undefined);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createSessionCredentialProvider, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './provider.js';
|
|
1
|
+
export { createSessionCredentialProvider, delegationCoverageMs, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './provider.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createSessionCredentialProvider, } from './provider.js';
|
|
1
|
+
export { createSessionCredentialProvider, delegationCoverageMs, } from './provider.js';
|
|
@@ -48,4 +48,9 @@ export interface SessionCredentialProvider extends SessionAuth {
|
|
|
48
48
|
/** Stop anticipating; a mint already in flight still settles. */
|
|
49
49
|
stop(): void;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Remaining lifetime a credential must exceed to carry a delegation of `ttlSeconds`: the TTL itself
|
|
53
|
+
* plus the settlement skew, so it cannot expire between admission and delivery.
|
|
54
|
+
*/
|
|
55
|
+
export declare function delegationCoverageMs(ttlSeconds: number): number;
|
|
51
56
|
export declare function createSessionCredentialProvider(options: SessionCredentialProviderOptions): SessionCredentialProvider;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { ClientError } from '../../errors/index.js';
|
|
2
2
|
import { BACKOFF_MS, MINT_TIMEOUT_MS, RETRY_INTERVAL_MS, SETTLEMENT_SKEW_MS } from './limits.js';
|
|
3
|
+
/**
|
|
4
|
+
* Remaining lifetime a credential must exceed to carry a delegation of `ttlSeconds`: the TTL itself
|
|
5
|
+
* plus the settlement skew, so it cannot expire between admission and delivery.
|
|
6
|
+
*/
|
|
7
|
+
export function delegationCoverageMs(ttlSeconds) {
|
|
8
|
+
return ttlSeconds * 1_000 + SETTLEMENT_SKEW_MS;
|
|
9
|
+
}
|
|
3
10
|
export function createSessionCredentialProvider(options) {
|
|
4
11
|
if (options === null || typeof options !== 'object') {
|
|
5
12
|
throw new TypeError('Session credential provider options are invalid.');
|
|
@@ -17,7 +24,7 @@ export function createSessionCredentialProvider(options) {
|
|
|
17
24
|
const mint = options.mint.bind(options);
|
|
18
25
|
const notify = options.onChange?.bind(options);
|
|
19
26
|
// The threshold is the coverage the caller already declared, never a separate guessed constant.
|
|
20
|
-
const marginMs = ttlSeconds
|
|
27
|
+
const marginMs = delegationCoverageMs(ttlSeconds);
|
|
21
28
|
let current = options.initial === undefined ? undefined : admitInitial(options.initial);
|
|
22
29
|
let state = Object.freeze(current === undefined
|
|
23
30
|
? { status: 'empty' }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
|
|
2
|
+
import type { invocation } from '@astrale-os/kernel-protocol';
|
|
3
|
+
import { Path } from '@astrale-os/kernel-core/path';
|
|
4
|
+
import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
|
|
5
|
+
import type { SessionOperation } from '../context/index.js';
|
|
6
|
+
import type { EffectiveAuth } from '../routing/index.js';
|
|
7
|
+
import type { ExchangeDependencies } from './exchange.js';
|
|
8
|
+
/** Installed issuers of the Domains one Session reaches. */
|
|
9
|
+
export interface Installations {
|
|
10
|
+
issuerOf(origin: Key.Origin, source: EffectiveAuth, operation: SessionOperation): Promise<IssuerId | undefined>;
|
|
11
|
+
clear(): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The origin of the Domain whose declaration a Call executes, or undefined when the target names no
|
|
15
|
+
* Domain. An instance Method belongs to its qualified Class, never to its receiver's Domain.
|
|
16
|
+
*/
|
|
17
|
+
export declare function declaringOrigin(target: invocation.Call['target']): Key.Origin | undefined;
|
|
18
|
+
export declare function callableOrigin(path: Path): Key.Origin | undefined;
|
|
19
|
+
export declare function isKernelOrigin(origin: Key.Origin): boolean;
|
|
20
|
+
/** The installed remote issuer of one Domain, read as the source caller; undefined when local. */
|
|
21
|
+
export declare function installedIssuer(dependencies: Pick<ExchangeDependencies, 'kernel' | 'source' | 'limits'>, origin: Key.Origin, source: EffectiveAuth, operation: SessionOperation): Promise<IssuerId | undefined>;
|
|
22
|
+
/**
|
|
23
|
+
* Remember each origin's installed issuer for the Session route age. The issuer is installation
|
|
24
|
+
* state, not authority, so one inspection serves every source credential; concurrent readers share
|
|
25
|
+
* it while each keeps its own cancellation and deadline.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createInstallations(dependencies: ExchangeDependencies): Installations;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { targetPath } from '@astrale-os/kernel-core/callable';
|
|
2
|
+
import { Path } from '@astrale-os/kernel-core/path';
|
|
3
|
+
import { identity } from '@astrale-os/kernel-core/schema/kernel/syscalls/references';
|
|
4
|
+
import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
|
|
5
|
+
import { createSchema } from '../../schema/index.js';
|
|
6
|
+
import { runSessionOperation } from '../context/index.js';
|
|
7
|
+
import { sourceCaller } from './source.js';
|
|
8
|
+
const KERNEL_ORIGIN = identity.whoami.owner.origin;
|
|
9
|
+
/**
|
|
10
|
+
* The origin of the Domain whose declaration a Call executes, or undefined when the target names no
|
|
11
|
+
* Domain. An instance Method belongs to its qualified Class, never to its receiver's Domain.
|
|
12
|
+
*/
|
|
13
|
+
export function declaringOrigin(target) {
|
|
14
|
+
return callableOrigin(Path.parse(targetPath(target)));
|
|
15
|
+
}
|
|
16
|
+
export function callableOrigin(path) {
|
|
17
|
+
const last = path.ast.steps.at(-1);
|
|
18
|
+
if (last?.kind === 'method' && last.dispatch === 'instance')
|
|
19
|
+
return Key.origin(last.class);
|
|
20
|
+
return path.ast.anchor.kind === 'domain' ? path.ast.anchor.origin : undefined;
|
|
21
|
+
}
|
|
22
|
+
export function isKernelOrigin(origin) {
|
|
23
|
+
return origin === KERNEL_ORIGIN;
|
|
24
|
+
}
|
|
25
|
+
/** The installed remote issuer of one Domain, read as the source caller; undefined when local. */
|
|
26
|
+
export async function installedIssuer(dependencies, origin, source, operation) {
|
|
27
|
+
const caller = sourceCaller(dependencies, source, operation);
|
|
28
|
+
const domain = await createSchema(caller.dispatch, dependencies.limits).inspect(origin);
|
|
29
|
+
const remote = domain.publication;
|
|
30
|
+
return remote === null || remote.identity.issuer === dependencies.kernel
|
|
31
|
+
? undefined
|
|
32
|
+
: remote.identity.issuer;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Remember each origin's installed issuer for the Session route age. The issuer is installation
|
|
36
|
+
* state, not authority, so one inspection serves every source credential; concurrent readers share
|
|
37
|
+
* it while each keeps its own cancellation and deadline.
|
|
38
|
+
*/
|
|
39
|
+
export function createInstallations(dependencies) {
|
|
40
|
+
/** An inspection in flight never expires; a settled one lives for the route age. */
|
|
41
|
+
const entries = new Map();
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
issuerOf(origin, source, operation) {
|
|
44
|
+
const held = entries.get(origin);
|
|
45
|
+
const { issuer } = held === undefined || held.expiresAt <= Date.now() ? inspect(origin, source) : held;
|
|
46
|
+
return runSessionOperation(operation, () => issuer);
|
|
47
|
+
},
|
|
48
|
+
clear: () => entries.clear(),
|
|
49
|
+
});
|
|
50
|
+
function inspect(origin, source) {
|
|
51
|
+
const issuer = installedIssuer(dependencies, origin, source, dependencies.operation());
|
|
52
|
+
const entry = { issuer, expiresAt: Infinity };
|
|
53
|
+
entries.set(origin, entry);
|
|
54
|
+
// Both reactions are attached, so a rejection that reaches no waiter is never unhandled.
|
|
55
|
+
issuer.then(() => {
|
|
56
|
+
entry.expiresAt = Date.now() + dependencies.policy.maximumRouteAgeMs;
|
|
57
|
+
}, () => {
|
|
58
|
+
if (entries.get(origin) === entry)
|
|
59
|
+
entries.delete(origin);
|
|
60
|
+
});
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { MintedCredential, UnresolvedIdentityExpr } from '@astrale-os/kernel-core/auth';
|
|
2
|
+
import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
|
|
3
|
+
import type { Client } from '../../client/index.js';
|
|
4
|
+
import type { SchemaOptions } from '../../schema/schema.js';
|
|
5
|
+
import type { Fetch } from '../../transport/http/index.js';
|
|
6
|
+
import type { SessionOperation, SessionPolicy } from '../context/index.js';
|
|
7
|
+
import type { SessionCredential } from '../credential/index.js';
|
|
8
|
+
import type { EffectiveAuth, SessionAuth } from '../routing/index.js';
|
|
9
|
+
import type { ExchangeRequest } from './options.js';
|
|
10
|
+
/** Everything an exchange reaches: the Session's source Kernel and authority, and the Domain legs. */
|
|
11
|
+
export interface ExchangeDependencies {
|
|
12
|
+
readonly kernel: IssuerId;
|
|
13
|
+
/** Unauthenticated Client for the source Kernel invocation endpoint. */
|
|
14
|
+
readonly source: () => Client;
|
|
15
|
+
/** The Session's source authority, when the Session has one. */
|
|
16
|
+
readonly auth?: SessionAuth;
|
|
17
|
+
readonly fetch: Fetch;
|
|
18
|
+
readonly policy: Readonly<SessionPolicy>;
|
|
19
|
+
readonly limits: SchemaOptions;
|
|
20
|
+
readonly maximumResponseBytes: number;
|
|
21
|
+
/** Open one operation for work shared by several Calls; it fails once the Session closes. */
|
|
22
|
+
readonly operation: (signal?: AbortSignal) => SessionOperation;
|
|
23
|
+
}
|
|
24
|
+
/** One Domain-issued Kernel credential and the delegation the Domain exchanged for it. */
|
|
25
|
+
export interface DomainCredential extends SessionCredential {
|
|
26
|
+
readonly delegation: MintedCredential;
|
|
27
|
+
}
|
|
28
|
+
export interface DomainExchangeRequest {
|
|
29
|
+
readonly issuer: IssuerId;
|
|
30
|
+
/** Requested delegation lifetime; the source credential's remaining lifetime bounds it. */
|
|
31
|
+
readonly ttlSeconds: number;
|
|
32
|
+
readonly attenuation: UnresolvedIdentityExpr;
|
|
33
|
+
/**
|
|
34
|
+
* Lifetime the returned credential must exceed. A source that bounds the delegation within it is
|
|
35
|
+
* exhausted rather than exchanged.
|
|
36
|
+
*/
|
|
37
|
+
readonly coverageMs?: number;
|
|
38
|
+
}
|
|
39
|
+
/** The attenuation that delegates the source caller's own identity. */
|
|
40
|
+
export declare const SELF_ATTENUATION: Readonly<{
|
|
41
|
+
readonly kind: 'identity';
|
|
42
|
+
readonly self: true;
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* Delegate the source caller to one Domain issuer and redeem that delegation at the issuer's
|
|
46
|
+
* advertised endpoint for the Domain's credential to the source Kernel.
|
|
47
|
+
*/
|
|
48
|
+
export declare function exchangeDomainCredential(dependencies: ExchangeDependencies, source: EffectiveAuth, request: DomainExchangeRequest, operation: SessionOperation): Promise<DomainCredential>;
|
|
49
|
+
/**
|
|
50
|
+
* One Domain credential for a named Domain issuer, as `session.exchange` returns it. When the
|
|
51
|
+
* request asks for more than the Session delegation, the credential covers that delegation, and the
|
|
52
|
+
* source is renewed once if the current one can no longer feed it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function acquireDomainCredential(dependencies: ExchangeDependencies, request: ExchangeRequest, operation: SessionOperation): Promise<SessionCredential>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { credentialApi, IDENTITY_DELEGATE_PATH } from '../../auth/credential.js';
|
|
2
|
+
import { identityApi } from '../../auth/identity.js';
|
|
3
|
+
import { delegationCoverageMs } from '../credential/index.js';
|
|
4
|
+
import { resolveAuth } from '../routing/index.js';
|
|
5
|
+
import { redeem, tokenEndpoint } from './issuer.js';
|
|
6
|
+
import { delegationTtl, isAuthority, renewing, sourceCaller, sourceExhausted } from './source.js';
|
|
7
|
+
/** The attenuation that delegates the source caller's own identity. */
|
|
8
|
+
export const SELF_ATTENUATION = Object.freeze({ kind: 'identity', self: true });
|
|
9
|
+
/**
|
|
10
|
+
* Delegate the source caller to one Domain issuer and redeem that delegation at the issuer's
|
|
11
|
+
* advertised endpoint for the Domain's credential to the source Kernel.
|
|
12
|
+
*/
|
|
13
|
+
export async function exchangeDomainCredential(dependencies, source, request, operation) {
|
|
14
|
+
const caller = sourceCaller(dependencies, source, operation);
|
|
15
|
+
const [endpoint, principal] = await Promise.all([
|
|
16
|
+
tokenEndpoint(dependencies, request.issuer, operation),
|
|
17
|
+
identityApi(caller.call)
|
|
18
|
+
.whoami()
|
|
19
|
+
.then(({ id }) => id),
|
|
20
|
+
]);
|
|
21
|
+
const ttlSeconds = delegationTtl(source.credential, request.ttlSeconds, request.coverageMs);
|
|
22
|
+
const delegation = await credentialApi(caller.call).delegate(principal, {
|
|
23
|
+
audience: request.issuer,
|
|
24
|
+
ttlSeconds,
|
|
25
|
+
attenuation: request.attenuation,
|
|
26
|
+
});
|
|
27
|
+
const credential = await redeem(dependencies, request.issuer, endpoint, delegation, operation);
|
|
28
|
+
// A Domain credential never outlives its source, so only a fresher source can lengthen it.
|
|
29
|
+
const bySource = ttlSeconds < request.ttlSeconds;
|
|
30
|
+
if (bySource && credential.expiresAt - Date.now() <= (request.coverageMs ?? 0)) {
|
|
31
|
+
throw sourceExhausted();
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze({ ...credential, delegation });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One Domain credential for a named Domain issuer, as `session.exchange` returns it. When the
|
|
37
|
+
* request asks for more than the Session delegation, the credential covers that delegation, and the
|
|
38
|
+
* source is renewed once if the current one can no longer feed it.
|
|
39
|
+
*/
|
|
40
|
+
export async function acquireDomainCredential(dependencies, request, operation) {
|
|
41
|
+
const { auth } = dependencies;
|
|
42
|
+
const call = {
|
|
43
|
+
target: IDENTITY_DELEGATE_PATH,
|
|
44
|
+
input: { audience: request.issuer, ttlSeconds: request.ttlSeconds },
|
|
45
|
+
};
|
|
46
|
+
const coverageMs = auth === undefined ? 0 : delegationCoverageMs(auth.ttlSeconds);
|
|
47
|
+
const exchange = (source) => exchangeDomainCredential(dependencies, source, {
|
|
48
|
+
issuer: request.issuer,
|
|
49
|
+
ttlSeconds: request.ttlSeconds,
|
|
50
|
+
attenuation: source.delegate?.attenuation ?? SELF_ATTENUATION,
|
|
51
|
+
...(request.ttlSeconds * 1_000 > coverageMs ? { coverageMs } : {}),
|
|
52
|
+
}, operation);
|
|
53
|
+
const source = await resolveAuth(auth, call, operation);
|
|
54
|
+
const exchanged = auth === undefined || !isAuthority(source)
|
|
55
|
+
? await exchange(source)
|
|
56
|
+
: await renewing(auth, call, source, operation, exchange);
|
|
57
|
+
return Object.freeze({ credential: exchanged.credential, expiresAt: exchanged.expiresAt });
|
|
58
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { callableOrigin, installedIssuer } from './domain.js';
|
|
2
|
+
export { acquireDomainCredential, exchangeDomainCredential, SELF_ATTENUATION, type DomainCredential, type DomainExchangeRequest, type ExchangeDependencies, } from './exchange.js';
|
|
3
|
+
export { admitExchangeOptions, admitExchangeRequest, type ExchangeRequest, type SessionExchangeOptions, type SessionExchangeRequest, } from './options.js';
|
|
4
|
+
export { createSessionExchange, type SessionExchange } from './presentation.js';
|
|
5
|
+
export { isAuthority } from './source.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { callableOrigin, installedIssuer } from './domain.js';
|
|
2
|
+
export { acquireDomainCredential, exchangeDomainCredential, SELF_ATTENUATION, } from './exchange.js';
|
|
3
|
+
export { admitExchangeOptions, admitExchangeRequest, } from './options.js';
|
|
4
|
+
export { createSessionExchange } from './presentation.js';
|
|
5
|
+
export { isAuthority } from './source.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MintedCredential } from '@astrale-os/kernel-core/auth';
|
|
2
|
+
import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
|
|
3
|
+
import type { SessionOperation } from '../context/index.js';
|
|
4
|
+
import type { SessionCredential } from '../credential/index.js';
|
|
5
|
+
import type { ExchangeDependencies } from './exchange.js';
|
|
6
|
+
type IssuerDependencies = Pick<ExchangeDependencies, 'kernel' | 'fetch' | 'policy' | 'maximumResponseBytes'>;
|
|
7
|
+
/**
|
|
8
|
+
* The token exchange endpoint a Domain issuer advertises. Endpoint policy applies here, so a denied
|
|
9
|
+
* endpoint is never returned and no delegation is minted for it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function tokenEndpoint(dependencies: IssuerDependencies, domain: IssuerId, operation: SessionOperation): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Present a delegation at a Domain's token endpoint and admit the returned credential as provider
|
|
14
|
+
* evidence: its issuer must be that Domain, its audience the source Kernel, and its signed expiry
|
|
15
|
+
* the reported one.
|
|
16
|
+
*/
|
|
17
|
+
export declare function redeem(dependencies: IssuerDependencies, domain: IssuerId, endpoint: string, delegation: MintedCredential, operation: SessionOperation): Promise<SessionCredential>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { inspect } from '@astrale-os/kernel-core/auth/credential';
|
|
2
|
+
import { codecs, issuer } from '@astrale-os/kernel-protocol';
|
|
3
|
+
import { ProtocolError } from '@astrale-os/kernel-protocol/errors';
|
|
4
|
+
import { ClientError, ExchangeError, SessionError } from '../../errors/index.js';
|
|
5
|
+
import { allowEndpoint, runSessionOperation } from '../context/index.js';
|
|
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' });
|
|
9
|
+
/**
|
|
10
|
+
* The token exchange endpoint a Domain issuer advertises. Endpoint policy applies here, so a denied
|
|
11
|
+
* endpoint is never returned and no delegation is minted for it.
|
|
12
|
+
*/
|
|
13
|
+
export async function tokenEndpoint(dependencies, domain, operation) {
|
|
14
|
+
const configuration = await fetchAdmitted(dependencies, DISCOVERY, new URL(issuer.paths(domain).configuration, domain).href, operation, (body) => issuer.acceptConfiguration(body, domain));
|
|
15
|
+
const endpoint = configuration.token_exchange_endpoint;
|
|
16
|
+
if (endpoint === undefined) {
|
|
17
|
+
throw new ExchangeError('The installed Domain does not advertise token exchange.', {
|
|
18
|
+
failure: 'unsupported',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
allowEndpoint(endpoint, dependencies.policy);
|
|
22
|
+
return endpoint;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Present a delegation at a Domain's token endpoint and admit the returned credential as provider
|
|
26
|
+
* evidence: its issuer must be that Domain, its audience the source Kernel, and its signed expiry
|
|
27
|
+
* the reported one.
|
|
28
|
+
*/
|
|
29
|
+
export async function redeem(dependencies, domain, endpoint, delegation, operation) {
|
|
30
|
+
const exchanged = await fetchAdmitted(dependencies, TOKEN_EXCHANGE, endpoint, operation, (body) => issuer.exchange.acceptResponse(body), delegation);
|
|
31
|
+
let claims;
|
|
32
|
+
try {
|
|
33
|
+
claims = inspect(exchanged.token);
|
|
34
|
+
}
|
|
35
|
+
catch (cause) {
|
|
36
|
+
throw invalidResponse('Domain token exchange returned an invalid credential.', cause);
|
|
37
|
+
}
|
|
38
|
+
if (claims.iss !== domain ||
|
|
39
|
+
claims.aud !== dependencies.kernel ||
|
|
40
|
+
claims.claims.exp !== exchanged.expiresAt) {
|
|
41
|
+
throw invalidResponse('Domain token exchange returned an inconsistent credential.');
|
|
42
|
+
}
|
|
43
|
+
const expiresAt = exchanged.expiresAt * 1_000;
|
|
44
|
+
if (expiresAt <= Date.now()) {
|
|
45
|
+
throw invalidResponse('Domain token exchange returned an expired credential.');
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze({ credential: exchanged.token, expiresAt });
|
|
48
|
+
}
|
|
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) {
|
|
51
|
+
allowEndpoint(url, dependencies.policy);
|
|
52
|
+
return runSessionOperation(operation, async (signal) => {
|
|
53
|
+
const init = {
|
|
54
|
+
method: credential === undefined ? 'GET' : 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
accept: credential === undefined ? 'application/json' : issuer.exchange.MEDIA_TYPE,
|
|
57
|
+
...(credential === undefined ? {} : { authorization: `Bearer ${credential}` }),
|
|
58
|
+
},
|
|
59
|
+
credentials: 'omit',
|
|
60
|
+
cache: 'no-store',
|
|
61
|
+
referrerPolicy: 'no-referrer',
|
|
62
|
+
redirect: 'manual',
|
|
63
|
+
signal,
|
|
64
|
+
};
|
|
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
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
requireExactResponse(response, url, 'Domain token exchange');
|
|
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);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
// Also release unread bodies rejected by URL, status, or declared-size validation.
|
|
88
|
+
void response.body?.cancel().catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
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) {
|
|
102
|
+
try {
|
|
103
|
+
const body = await boundedResponse(response, dependencies.maximumResponseBytes, signal);
|
|
104
|
+
return new ProtocolError(issuer.exchange.acceptErrorResponse(codecs.json.decode(body)).error);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
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
|
+
}
|