@open-everywhere/adapters 0.6.0 → 0.8.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/agent/aws/sso.ts +0 -2
- package/agent/directory/okta.ts +41 -2
- package/agent/instrumentation.ts +67 -4
- package/agent/model/approval-judge.ts +3 -3
- package/agent/model/dream-judge.ts +8 -6
- package/agent/model/gateway-probe.ts +97 -11
- package/agent/model/gateway-vision.ts +10 -6
- package/agent/model/gateway.ts +448 -34
- package/agent/model/meeting-gateway.ts +37 -7
- package/agent/model/model-config-check.ts +243 -0
- package/agent/model/target-url.ts +38 -0
- package/agent/model/transcription-routes.ts +18 -4
- package/agent/oe-oauth/config.ts +71 -3
- package/agent/oe-oauth/errors.ts +18 -1
- package/agent/oe-oauth/oauth-client.ts +118 -2
- package/agent/oe-oauth/token-vault.ts +446 -25
- package/agent/oe-oauth/user-auth-flow.ts +4 -3
- package/agent/operator-holders.ts +47 -0
- package/agent/postgres/neon.ts +1 -1
- package/dist/agent/aws/sso.d.ts +1 -2
- package/dist/agent/aws/sso.d.ts.map +1 -1
- package/dist/agent/aws/sso.js +0 -2
- package/dist/agent/aws/sso.js.map +1 -1
- package/dist/agent/directory/okta.d.ts +10 -0
- package/dist/agent/directory/okta.d.ts.map +1 -1
- package/dist/agent/directory/okta.js +34 -2
- package/dist/agent/directory/okta.js.map +1 -1
- package/dist/agent/instrumentation.d.ts +23 -1
- package/dist/agent/instrumentation.d.ts.map +1 -1
- package/dist/agent/instrumentation.js +59 -3
- package/dist/agent/instrumentation.js.map +1 -1
- package/dist/agent/model/approval-judge.d.ts.map +1 -1
- package/dist/agent/model/approval-judge.js +3 -3
- package/dist/agent/model/approval-judge.js.map +1 -1
- package/dist/agent/model/dream-judge.d.ts +2 -0
- package/dist/agent/model/dream-judge.d.ts.map +1 -1
- package/dist/agent/model/dream-judge.js +6 -4
- package/dist/agent/model/dream-judge.js.map +1 -1
- package/dist/agent/model/gateway-probe.d.ts +22 -4
- package/dist/agent/model/gateway-probe.d.ts.map +1 -1
- package/dist/agent/model/gateway-probe.js +78 -11
- package/dist/agent/model/gateway-probe.js.map +1 -1
- package/dist/agent/model/gateway-vision.d.ts +4 -2
- package/dist/agent/model/gateway-vision.d.ts.map +1 -1
- package/dist/agent/model/gateway-vision.js +6 -4
- package/dist/agent/model/gateway-vision.js.map +1 -1
- package/dist/agent/model/gateway.d.ts +85 -12
- package/dist/agent/model/gateway.d.ts.map +1 -1
- package/dist/agent/model/gateway.js +418 -30
- package/dist/agent/model/gateway.js.map +1 -1
- package/dist/agent/model/meeting-gateway.d.ts +8 -2
- package/dist/agent/model/meeting-gateway.d.ts.map +1 -1
- package/dist/agent/model/meeting-gateway.js +29 -7
- package/dist/agent/model/meeting-gateway.js.map +1 -1
- package/dist/agent/model/model-config-check.d.ts +57 -0
- package/dist/agent/model/model-config-check.d.ts.map +1 -0
- package/dist/agent/model/model-config-check.js +207 -0
- package/dist/agent/model/model-config-check.js.map +1 -0
- package/dist/agent/model/target-url.d.ts +10 -0
- package/dist/agent/model/target-url.d.ts.map +1 -0
- package/dist/agent/model/target-url.js +32 -0
- package/dist/agent/model/target-url.js.map +1 -0
- package/dist/agent/model/transcription-routes.d.ts +3 -0
- package/dist/agent/model/transcription-routes.d.ts.map +1 -1
- package/dist/agent/model/transcription-routes.js +16 -4
- package/dist/agent/model/transcription-routes.js.map +1 -1
- package/dist/agent/oe-oauth/config.d.ts +28 -2
- package/dist/agent/oe-oauth/config.d.ts.map +1 -1
- package/dist/agent/oe-oauth/config.js +40 -1
- package/dist/agent/oe-oauth/config.js.map +1 -1
- package/dist/agent/oe-oauth/errors.d.ts +14 -7
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -1
- package/dist/agent/oe-oauth/errors.js +16 -1
- package/dist/agent/oe-oauth/errors.js.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.d.ts +46 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.js +82 -1
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -1
- package/dist/agent/oe-oauth/token-vault.d.ts +19 -3
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
- package/dist/agent/oe-oauth/token-vault.js +396 -22
- package/dist/agent/oe-oauth/token-vault.js.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.js +3 -2
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -1
- package/dist/agent/operator-holders.d.ts +13 -0
- package/dist/agent/operator-holders.d.ts.map +1 -0
- package/dist/agent/operator-holders.js +40 -0
- package/dist/agent/operator-holders.js.map +1 -0
- package/dist/agent/postgres/neon.d.ts +0 -15
- package/dist/agent/postgres/neon.d.ts.map +1 -1
- package/dist/agent/postgres/neon.js +1 -1
- package/dist/agent/postgres/neon.js.map +1 -1
- package/package.json +20 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// THE LIVE MODEL CONFIG CHECK (OPE-2556, the OPE-2535 ticket's comment #2). It answers, BEFORE a call fails, whether
|
|
2
|
+
// the deployment's model config can be served: for each model the caller lists (the stack roles, the gateway
|
|
3
|
+
// transcription routes, the domain and role routes, `models.force`), one GET of the gateway's
|
|
4
|
+
// public `/v1/models/<id>/endpoints` read, and three questions:
|
|
5
|
+
//
|
|
6
|
+
// ① does the model exist on the gateway?
|
|
7
|
+
// ② does at least one `providers.only` provider serve it? (A slug that serves none of the checked models gets a
|
|
8
|
+
// warning, because it is most likely a typo. It is not a failure: every call can still be served.)
|
|
9
|
+
// ③ does at least one allowed provider meet EVERY `models.dataPolicy` row at once (`has_zdr`, `has_no_training`,
|
|
10
|
+
// `inference_regions`)?
|
|
11
|
+
//
|
|
12
|
+
// A row no provider can meet is named before deploy, for example "region eu: no provider for deepseek/…".
|
|
13
|
+
//
|
|
14
|
+
// 🔴 THE ENDPOINT FIELDS ARE UNDOCUMENTED, so a missing field is UNKNOWN, never a pass. One rule reads more than a
|
|
15
|
+
// field: `inference_regions`. Most endpoints omit it. When at least one endpoint of a model lists regions, an endpoint
|
|
16
|
+
// that omits them is read as serving none of the listed regions: that is how the gateway answered on 2026-10-14
|
|
17
|
+
// ("No provider for deepseek/deepseek-v4-flash-0731 supports the requested inference region", where only some
|
|
18
|
+
// endpoints listed `us` and none listed `eu`). When no endpoint lists regions, the region row is unknown.
|
|
19
|
+
//
|
|
20
|
+
// It needs the network, so it is never an offline gate. Three readers run it: the deployment status probe
|
|
21
|
+
// (`model-route`, gateway-probe.ts), which `create --check --host` and the setup skill read, and
|
|
22
|
+
// `npm run check:model-config`, which runs it from the app (a clone or a scaffolded app) before a deploy.
|
|
23
|
+
|
|
24
|
+
import { type ModelDataPolicy, type ModelTarget, mergeDataPolicy } from "@open-everywhere/core/agent/adapter-api.js";
|
|
25
|
+
|
|
26
|
+
/** The default origin of the Vercel AI Gateway's public model reads. */
|
|
27
|
+
const DEFAULT_GATEWAY_ORIGIN = "https://ai-gateway.vercel.sh";
|
|
28
|
+
|
|
29
|
+
export type CheckVerdict = "yes" | "no" | "unknown";
|
|
30
|
+
|
|
31
|
+
/** One endpoint (one provider serving one model), as the check reads it. `undefined` is an unknown field. */
|
|
32
|
+
export interface EndpointFacts {
|
|
33
|
+
readonly provider: string;
|
|
34
|
+
readonly zdr: boolean | undefined;
|
|
35
|
+
readonly noTraining: boolean | undefined;
|
|
36
|
+
/** The regions the endpoint lists, or undefined when the endpoint omits the field. */
|
|
37
|
+
readonly regions: readonly string[] | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A model the deployment can resolve, and the target its calls go through. */
|
|
41
|
+
export interface CheckedModel {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
/** The `models.targets` entry (absent: `gateway`). */
|
|
44
|
+
readonly via?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One problem or gap the check found. `row` names the demand in the settings file's words. */
|
|
48
|
+
export interface ConfigFinding {
|
|
49
|
+
readonly met: Exclude<CheckVerdict, "yes">;
|
|
50
|
+
readonly model?: string;
|
|
51
|
+
readonly row: string;
|
|
52
|
+
readonly detail: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ConfigCheckResult {
|
|
56
|
+
/** "no" when any finding is "no"; "unknown" when any is "unknown"; else "yes". */
|
|
57
|
+
readonly met: CheckVerdict;
|
|
58
|
+
readonly findings: readonly ConfigFinding[];
|
|
59
|
+
/** How many distinct (target, model) pairs were read. */
|
|
60
|
+
readonly checked: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The endpoints read for one model id under a target's base URL (or the default origin). The id keeps its slash. */
|
|
64
|
+
export function modelEndpointsUrl(id: string, baseUrl?: string): string {
|
|
65
|
+
let origin = DEFAULT_GATEWAY_ORIGIN;
|
|
66
|
+
if (baseUrl !== undefined) {
|
|
67
|
+
try {
|
|
68
|
+
origin = new URL(baseUrl).origin;
|
|
69
|
+
} catch {
|
|
70
|
+
// The settings validator refuses an unparseable url; the default origin keeps the check honest.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return `${origin}/v1/models/${id.split("/").map(encodeURIComponent).join("/")}/endpoints`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const asBool = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
|
|
77
|
+
|
|
78
|
+
/** Parse an `/endpoints` body into facts, or undefined when the body is not one. Pure. */
|
|
79
|
+
export function parseEndpoints(body: unknown): EndpointFacts[] | undefined {
|
|
80
|
+
const eps = (body as { data?: { endpoints?: unknown } } | null)?.data?.endpoints;
|
|
81
|
+
if (!Array.isArray(eps)) return undefined;
|
|
82
|
+
const out: EndpointFacts[] = [];
|
|
83
|
+
for (const e of eps) {
|
|
84
|
+
if (e === null || typeof e !== "object") continue;
|
|
85
|
+
const r = e as Record<string, unknown>;
|
|
86
|
+
if (typeof r["provider_name"] !== "string") continue;
|
|
87
|
+
const regions = Array.isArray(r["inference_regions"])
|
|
88
|
+
? (r["inference_regions"] as unknown[]).flatMap((x) => {
|
|
89
|
+
const g = (x as { geo_region?: unknown } | null)?.geo_region;
|
|
90
|
+
return typeof g === "string" ? [g] : [];
|
|
91
|
+
})
|
|
92
|
+
: undefined;
|
|
93
|
+
out.push({ provider: r["provider_name"], zdr: asBool(r["has_zdr"]), noTraining: asBool(r["has_no_training"]), regions });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const and = (a: CheckVerdict, b: CheckVerdict): CheckVerdict => (a === "no" || b === "no" ? "no" : a === "unknown" || b === "unknown" ? "unknown" : "yes");
|
|
99
|
+
/** At least one provider: "yes" when one is, "no" when every one is not (or there is none), else "unknown". */
|
|
100
|
+
const any = (vs: readonly CheckVerdict[]): CheckVerdict => (vs.includes("yes") ? "yes" : vs.every((v) => v === "no") ? "no" : "unknown");
|
|
101
|
+
const fromBool = (b: boolean | undefined): CheckVerdict => (b === undefined ? "unknown" : b ? "yes" : "no");
|
|
102
|
+
|
|
103
|
+
/** The policy's rows as (label, per-endpoint verdict) pairs. */
|
|
104
|
+
function rowJudges(policy: ModelDataPolicy, eps: readonly EndpointFacts[]): { label: string; judge: (e: EndpointFacts) => CheckVerdict }[] {
|
|
105
|
+
const rows: { label: string; judge: (e: EndpointFacts) => CheckVerdict }[] = [];
|
|
106
|
+
if (policy.zeroRetention === true) rows.push({ label: "zeroRetention", judge: (e) => fromBool(e.zdr) });
|
|
107
|
+
if (policy.noTraining === true) rows.push({ label: "noTraining", judge: (e) => fromBool(e.noTraining) });
|
|
108
|
+
const region = policy.region;
|
|
109
|
+
if (region !== undefined) {
|
|
110
|
+
const listed = eps.some((e) => e.regions !== undefined);
|
|
111
|
+
rows.push({ label: `region ${region}`, judge: (e) => (e.regions !== undefined ? fromBool(e.regions.includes(region)) : listed ? "no" : "unknown") });
|
|
112
|
+
}
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Judge one model's endpoints against a target and the policy. Pure. Returns the findings ("no" and "unknown" only),
|
|
118
|
+
* so an empty list means every demand is met.
|
|
119
|
+
*/
|
|
120
|
+
export function judgeModelEndpoints(id: string, eps: readonly EndpointFacts[], target: ModelTarget, policy: ModelDataPolicy, via = "gateway"): ConfigFinding[] {
|
|
121
|
+
const findings: ConfigFinding[] = [];
|
|
122
|
+
const only = target.providers?.only;
|
|
123
|
+
const candidates = only === undefined ? eps : eps.filter((e) => only.includes(e.provider));
|
|
124
|
+
if (only !== undefined && candidates.length === 0) {
|
|
125
|
+
findings.push({ met: "no", model: id, row: `models.targets.${via}.providers.only`, detail: `providers.only: no allowed provider serves ${id}` });
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
128
|
+
// The target's own `dataPolicy` rows join the deployment's (OPE-2531). Two different regions cannot both be met.
|
|
129
|
+
let merged: ModelDataPolicy;
|
|
130
|
+
try {
|
|
131
|
+
merged = mergeDataPolicy(policy, target.dataPolicy);
|
|
132
|
+
} catch {
|
|
133
|
+
findings.push({ met: "no", model: id, row: `models.targets.${via}.dataPolicy region`, detail: `region: models.dataPolicy and target ${via} name two different regions` });
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
// The region evidence is read over ALL the model's endpoints: whether the gateway lists regions for this model at all.
|
|
137
|
+
const rows = rowJudges(merged, eps);
|
|
138
|
+
if (rows.length === 0) return findings;
|
|
139
|
+
const joint = any(candidates.map((e) => rows.reduce<CheckVerdict>((v, r) => and(v, r.judge(e)), "yes")));
|
|
140
|
+
if (joint === "yes") return findings;
|
|
141
|
+
// Name each row no provider can meet on its own; when every row is met by SOME provider, name them together.
|
|
142
|
+
const alone = rows.map((r) => ({ label: r.label, v: any(candidates.map(r.judge)) }));
|
|
143
|
+
const failing = alone.filter((r) => r.v === joint);
|
|
144
|
+
const named = failing.length > 0 ? failing.map((r) => r.label) : [rows.map((r) => r.label).join(" + ")];
|
|
145
|
+
// A row the deployment's own policy does not set came from the target: name the target's row (OPE-2531).
|
|
146
|
+
const ownRows = rowJudges(policy, eps).map((r) => r.label);
|
|
147
|
+
for (const label of named) {
|
|
148
|
+
findings.push({
|
|
149
|
+
met: joint,
|
|
150
|
+
model: id,
|
|
151
|
+
row: label
|
|
152
|
+
.split(" + ")
|
|
153
|
+
.map((l) => (ownRows.includes(l) ? `models.dataPolicy ${l}` : `models.targets.${via}.dataPolicy ${l}`))
|
|
154
|
+
.join(" + "),
|
|
155
|
+
detail: joint === "no" ? `${label}: no provider for ${id}` : `${label}: the gateway does not say whether a provider of ${id} meets it`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return findings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ConfigCheckInput {
|
|
162
|
+
readonly models: readonly CheckedModel[];
|
|
163
|
+
readonly targets: Readonly<Record<string, ModelTarget>>;
|
|
164
|
+
readonly policy: ModelDataPolicy;
|
|
165
|
+
/** The base URL each target resolves to (absent: the default origin). */
|
|
166
|
+
readonly baseUrlFor: (via: string) => string | undefined;
|
|
167
|
+
readonly fetchImpl?: typeof globalThis.fetch;
|
|
168
|
+
/** One budget for every read. */
|
|
169
|
+
readonly timeoutMs?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run the check: one GET per distinct (target, model), in parallel, under one time budget. FAIL-SOFT: a read that
|
|
174
|
+
* fails or times out is "unknown" for that model, never a pass and never a throw. No vendor error text is forwarded.
|
|
175
|
+
*/
|
|
176
|
+
export async function checkModelConfig(input: ConfigCheckInput): Promise<ConfigCheckResult> {
|
|
177
|
+
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
178
|
+
const seen = new Map<string, CheckedModel & { via: string }>();
|
|
179
|
+
for (const m of input.models) {
|
|
180
|
+
const via = m.via ?? "gateway";
|
|
181
|
+
seen.set(`${via}\u0000${m.id}`, { id: m.id, via });
|
|
182
|
+
}
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const fuse = setTimeout(() => controller.abort(), input.timeoutMs ?? 5_000);
|
|
185
|
+
const servedBy = new Map<string, Set<string>>(); // via → providers that serve at least one checked model
|
|
186
|
+
try {
|
|
187
|
+
const perModel = await Promise.all(
|
|
188
|
+
[...seen.values()].map(async (m): Promise<ConfigFinding[]> => {
|
|
189
|
+
const target = input.targets[m.via];
|
|
190
|
+
if (target === undefined) return [{ met: "no", model: m.id, row: `via ${m.via}`, detail: `via ${m.via}: models.targets names no such target` }];
|
|
191
|
+
let res: Response;
|
|
192
|
+
try {
|
|
193
|
+
res = await fetchImpl(modelEndpointsUrl(m.id, input.baseUrlFor(m.via)), { method: "GET", signal: controller.signal });
|
|
194
|
+
} catch {
|
|
195
|
+
return [{ met: "unknown", model: m.id, row: "endpoints", detail: `${m.id}: the gateway did not answer the endpoints read` }];
|
|
196
|
+
}
|
|
197
|
+
// A retargeted target (a relay) owes nobody this read: its 404 or 405 means "not served here", not "no such model".
|
|
198
|
+
const retargeted = input.baseUrlFor(m.via) !== undefined;
|
|
199
|
+
if (retargeted && (res.status === 404 || res.status === 405)) {
|
|
200
|
+
return [{ met: "unknown", model: m.id, row: "endpoints", detail: `${m.id}: the target ${m.via} does not serve the endpoints read` }];
|
|
201
|
+
}
|
|
202
|
+
if (res.status === 404) return [{ met: "no", model: m.id, row: "model id", detail: `${m.id}: the gateway does not know this model` }];
|
|
203
|
+
if (!res.ok) return [{ met: "unknown", model: m.id, row: "endpoints", detail: `${m.id}: the endpoints read answered HTTP ${res.status}` }];
|
|
204
|
+
let eps: EndpointFacts[] | undefined;
|
|
205
|
+
try {
|
|
206
|
+
eps = parseEndpoints(await res.json());
|
|
207
|
+
} catch {
|
|
208
|
+
eps = undefined;
|
|
209
|
+
}
|
|
210
|
+
if (eps === undefined) return [{ met: "unknown", model: m.id, row: "endpoints", detail: `${m.id}: the endpoints read is not in the expected shape` }];
|
|
211
|
+
const served = servedBy.get(m.via) ?? new Set<string>();
|
|
212
|
+
for (const e of eps) served.add(e.provider);
|
|
213
|
+
servedBy.set(m.via, served);
|
|
214
|
+
return judgeModelEndpoints(m.id, eps, target, input.policy, m.via);
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
const findings = perModel.flat();
|
|
218
|
+
// A `providers.only` slug that serves none of the target's checked models: most likely a typo. It is a WARNING
|
|
219
|
+
// ("unknown"), never a "no": every call can still be served, and a deployer may list a provider for a later stack.
|
|
220
|
+
// Only judged when every model of the target was read, so a failed read never blames a slug.
|
|
221
|
+
for (const [via, served] of servedBy) {
|
|
222
|
+
const readAll = ![...seen.values()].some((m) => m.via === via && findings.some((f) => f.model === m.id && f.row === "endpoints"));
|
|
223
|
+
if (!readAll) continue;
|
|
224
|
+
for (const slug of input.targets[via]?.providers?.only ?? []) {
|
|
225
|
+
if (!served.has(slug)) findings.push({ met: "unknown", row: `models.targets.${via}.providers.only`, detail: `providers.only: "${slug}" serves none of the checked models` });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const met: CheckVerdict = findings.some((f) => f.met === "no") ? "no" : findings.length > 0 ? "unknown" : "yes";
|
|
229
|
+
return { met, findings, checked: seen.size };
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(fuse);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The result in one short line a person reads: "4 models checked, no problem", or the problems by name. Pure. */
|
|
236
|
+
export function configCheckSummary(r: ConfigCheckResult): string {
|
|
237
|
+
const plural = r.checked === 1 ? "model" : "models";
|
|
238
|
+
if (r.findings.length === 0) return `model config: ${r.checked} ${plural} checked, every demand can be served`;
|
|
239
|
+
const no = r.findings.filter((f) => f.met === "no");
|
|
240
|
+
const shown = (no.length > 0 ? no : r.findings).map((f) => f.detail);
|
|
241
|
+
const head = no.length > 0 ? `${no.length} problem${no.length === 1 ? "" : "s"}` : "not fully measured";
|
|
242
|
+
return `model config: ${head} — ${shown.slice(0, 3).join("; ")}${shown.length > 3 ? `; and ${shown.length - 3} more` : ""}`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// THE BASE URL OF A MODEL TARGET (OPE-2556) — `models.targets.<name>.url`, with the deprecated `OE_AI_GATEWAY_URL`
|
|
2
|
+
// as the `gateway` target's fallback for one release. Its own module so the model adapter (gateway.ts) and the
|
|
3
|
+
// status probe (gateway-probe.ts) resolve the same URL without the probe loading the adapter.
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_MODEL_VIA, type ModelTarget } from "@open-everywhere/core/agent/adapter-api.js";
|
|
6
|
+
|
|
7
|
+
/** The deprecated env var that retargets the `gateway` target (OPE-2556: `models.targets.gateway.url` replaces it). */
|
|
8
|
+
export const DEPRECATED_GATEWAY_URL_ENV = "OE_AI_GATEWAY_URL";
|
|
9
|
+
|
|
10
|
+
const warned = new Set<string>();
|
|
11
|
+
function warnOnce(key: string, message: string): void {
|
|
12
|
+
if (warned.has(key)) return;
|
|
13
|
+
warned.add(key);
|
|
14
|
+
console.warn(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE BASE URL OF ONE TARGET. The target's `url` wins. The deprecated `OE_AI_GATEWAY_URL` still retargets the
|
|
19
|
+
* `gateway` target when its `url` is unset, for one release, with a warning. Absent both: the kind's default
|
|
20
|
+
* endpoint (`undefined`). Pure but for the warnings; exported for tests.
|
|
21
|
+
*/
|
|
22
|
+
export function targetBaseUrl(name: string, t: ModelTarget, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
23
|
+
const fromEnv = name === DEFAULT_MODEL_VIA ? env[DEPRECATED_GATEWAY_URL_ENV]?.trim() || undefined : undefined;
|
|
24
|
+
if (t.url !== undefined) {
|
|
25
|
+
if (fromEnv !== undefined && fromEnv !== t.url) {
|
|
26
|
+
warnOnce("env-ignored", `[open-everywhere:model] ${DEPRECATED_GATEWAY_URL_ENV} is set and ignored: models.targets.${name}.url wins. Unset the env var.`);
|
|
27
|
+
}
|
|
28
|
+
return t.url;
|
|
29
|
+
}
|
|
30
|
+
if (fromEnv !== undefined) {
|
|
31
|
+
warnOnce(
|
|
32
|
+
"env-deprecated",
|
|
33
|
+
`[open-everywhere:model] ${DEPRECATED_GATEWAY_URL_ENV} is deprecated and goes away in the next release. Move it to models.targets.${name}.url in agent/settings/settings.ts.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return fromEnv;
|
|
37
|
+
}
|
|
38
|
+
|
|
@@ -18,12 +18,17 @@
|
|
|
18
18
|
// provider allow-list, a timeout) tries the fallback route. Both fail ⇒ it throws one error that names both. It
|
|
19
19
|
// never tries a third route: the stack names what may hear the meeting, and nothing else may.
|
|
20
20
|
//
|
|
21
|
+
// 🔴 THE DATA POLICY (OPE-2535). A `gateway` route carries `models.dataPolicy` on every call (gateway.ts). The
|
|
22
|
+
// `deepinfra` route cannot carry it, so with any row set it REFUSES before a byte leaves, and the fallback rule
|
|
23
|
+
// above does not let the audio go anywhere the policy did not allow. A `self-hosted` route is the deployer's own
|
|
24
|
+
// server, so the policy's provider rows do not apply to it.
|
|
25
|
+
//
|
|
21
26
|
// 🔴 THE DISCLOSURE IS DERIVED. The approval card shows {@link roleDisclosure}, built from the SAME route list the
|
|
22
27
|
// call walks, so the card cannot name one provider while the audio goes to another.
|
|
23
28
|
|
|
24
29
|
import { experimental_transcribe as transcribe } from "ai";
|
|
25
|
-
import { getOperatorSecret } from "@open-everywhere/core/agent/adapter-api.js";
|
|
26
|
-
import {
|
|
30
|
+
import { type ModelDataPolicy, dataPolicyActive, dataPolicyRows, getOperatorSecret, getSettings } from "@open-everywhere/core/agent/adapter-api.js";
|
|
31
|
+
import { gatewayTargetHost, gatewayTranscriptionModel, sharedHostPlaneFetch } from "./gateway.js";
|
|
27
32
|
|
|
28
33
|
/** How a route reaches its model. */
|
|
29
34
|
export type TranscriptionVia = "gateway" | "deepinfra" | "self-hosted";
|
|
@@ -82,6 +87,8 @@ export interface TranscriptionDeps {
|
|
|
82
87
|
readonly attemptTimeoutMs?: number;
|
|
83
88
|
/** Where a fall-through is reported. Default: `console.warn`. */
|
|
84
89
|
readonly warn?: (message: string) => void;
|
|
90
|
+
/** The deployment's data policy, read per call. Default: `models.dataPolicy` from the settings. */
|
|
91
|
+
readonly dataPolicy?: () => ModelDataPolicy;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
/** The secret-store name of the DeepInfra key. A fixed name in code, so no config line can point it at another secret. */
|
|
@@ -259,7 +266,7 @@ function selfHostedCall(f: typeof fetch) {
|
|
|
259
266
|
* union and neither the turn model nor a transcription route drops the other's host.
|
|
260
267
|
*/
|
|
261
268
|
export function roleHostPlaneHosts(role: TranscriptionRoleSpec): string[] {
|
|
262
|
-
const hosts = [
|
|
269
|
+
const hosts = [gatewayTargetHost()];
|
|
263
270
|
for (const r of [role.primary, role.fallback]) {
|
|
264
271
|
if (r === undefined || r.via === "gateway") continue;
|
|
265
272
|
const h = routeHost(r);
|
|
@@ -277,7 +284,14 @@ export function routeTransport(r: TranscriptionRouteSpec, deps: TranscriptionDep
|
|
|
277
284
|
const f = deps.fetch ?? (deps.hostPlane ?? sharedHostPlaneFetch)(roleHostPlaneHosts({ primary: r })) ?? lateFetch;
|
|
278
285
|
if (r.via === "deepinfra") {
|
|
279
286
|
const call = deepinfraCall(f, deps.secret ?? storeOnlySecret);
|
|
280
|
-
|
|
287
|
+
const policy = deps.dataPolicy ?? (() => getSettings().models.dataPolicy);
|
|
288
|
+
return async (req) => {
|
|
289
|
+
const p = policy();
|
|
290
|
+
if (dataPolicyActive(p)) {
|
|
291
|
+
throw new Error(`models.dataPolicy (${dataPolicyRows(p).join(", ")}): the deepinfra route cannot carry these rows, so it refuses`);
|
|
292
|
+
}
|
|
293
|
+
return await call(r, req);
|
|
294
|
+
};
|
|
281
295
|
}
|
|
282
296
|
const call = selfHostedCall(f);
|
|
283
297
|
return (req) => call(r, req);
|
package/agent/oe-oauth/config.ts
CHANGED
|
@@ -14,6 +14,9 @@ export const OE_OAUTH_MODE_DISCOVERY = "discovery-dcr-pkce";
|
|
|
14
14
|
/** The static confidential-client dialect (OPE-1086) — no discovery, no registration, HTTP Basic. */
|
|
15
15
|
export const OE_OAUTH_MODE_STATIC = "static-confidential";
|
|
16
16
|
|
|
17
|
+
/** The Cross App Access dialect (OPE-2453) — an IdP sign-in once, then an ID-JAG mint per app token. */
|
|
18
|
+
export const OE_OAUTH_MODE_XAA = "xaa";
|
|
19
|
+
|
|
17
20
|
/** How a static provider encodes its token-request body. Declared, never inferred: Notion's REST
|
|
18
21
|
* token endpoint takes JSON, the RFC 6749 default is form-encoded, and sending the wrong one reads
|
|
19
22
|
* as an authentication failure rather than as a shape error. */
|
|
@@ -66,8 +69,36 @@ export interface OeOauthStaticProviderConfig extends OeOauthProviderConfigBase {
|
|
|
66
69
|
readonly bodyEncoding: OeOauthBodyEncoding;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
72
|
+
/**
|
|
73
|
+
* A Cross App Access provider (OPE-2453). The person signs in to the IdP (`idpIssuer`) once; each mint then trades
|
|
74
|
+
* the person's IdP refresh token for an ID-JAG (RFC 8693, at the IdP) and the ID-JAG for the app's access token
|
|
75
|
+
* (RFC 7523, at `resourceTokenEndpoint`). Two confidential clients, two secret REFS, no literal secret.
|
|
76
|
+
*/
|
|
77
|
+
export interface OeOauthXaaProviderConfig extends OeOauthProviderConfigBase {
|
|
78
|
+
readonly mode: typeof OE_OAUTH_MODE_XAA;
|
|
79
|
+
/** The IdP's OIDC issuer. Its `/.well-known/openid-configuration` names the authorize, token, JWKS and
|
|
80
|
+
* revocation endpoints. HTTPS only. */
|
|
81
|
+
readonly idpIssuer: string;
|
|
82
|
+
readonly idpClientId: string;
|
|
83
|
+
readonly idpClientSecretRef: string;
|
|
84
|
+
readonly idpClientSecretBroker: string;
|
|
85
|
+
/** The app's authorization server issuer — the ID-JAG `audience`. */
|
|
86
|
+
readonly audience: string;
|
|
87
|
+
/** The RFC 8707 resource both legs name, when the app asks for one. */
|
|
88
|
+
readonly resource?: string;
|
|
89
|
+
readonly resourceTokenEndpoint: string;
|
|
90
|
+
readonly resourceClientId: string;
|
|
91
|
+
readonly resourceClientSecretRef: string;
|
|
92
|
+
readonly resourceClientSecretBroker: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One provider's OAuth target, in one of the three declared modes. */
|
|
96
|
+
export type OeOauthProviderConfig = OeOauthDiscoveryProviderConfig | OeOauthStaticProviderConfig | OeOauthXaaProviderConfig;
|
|
97
|
+
|
|
98
|
+
/** Narrow a provider config to the Cross App Access mode. Reads the DECLARED `mode` only. */
|
|
99
|
+
export function isXaa(cfg: OeOauthProviderConfig): cfg is OeOauthXaaProviderConfig {
|
|
100
|
+
return cfg.mode === OE_OAUTH_MODE_XAA;
|
|
101
|
+
}
|
|
71
102
|
|
|
72
103
|
/** Narrow a provider config to the static confidential-client mode. Reads the DECLARED `mode` and
|
|
73
104
|
* nothing else — never the presence of a field. */
|
|
@@ -162,6 +193,39 @@ function parseStaticProvider(ref: string, rec: Record<string, unknown>): OeOauth
|
|
|
162
193
|
};
|
|
163
194
|
}
|
|
164
195
|
|
|
196
|
+
/** Parse ONE `xaa` entry, with every refusal stated. */
|
|
197
|
+
function parseXaaProvider(ref: string, rec: Record<string, unknown>): OeOauthXaaProviderConfig {
|
|
198
|
+
const where = `sign-in "${ref}"`;
|
|
199
|
+
// 🔴 A LITERAL SECRET IS REFUSED, as in the static dialect: a declaration is committed source.
|
|
200
|
+
for (const literal of ["clientSecret", "idpClientSecret", "resourceClientSecret"]) {
|
|
201
|
+
if (literal in rec) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`${where} carries a literal "${literal}". A client secret must NEVER live in a sign-in declaration — store it in the ` +
|
|
204
|
+
`credential store and name it with "${literal === "clientSecret" ? "idpClientSecretRef" : `${literal}Ref`}".`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const resource = rec.resource === undefined ? undefined : requireString(rec.resource, `${where}.resource`);
|
|
209
|
+
const scopes = parseScopes(rec.scopes);
|
|
210
|
+
return {
|
|
211
|
+
mode: OE_OAUTH_MODE_XAA,
|
|
212
|
+
ref,
|
|
213
|
+
// The issuer is compared byte-for-byte with the id token's `iss`, so it is kept exactly as declared.
|
|
214
|
+
idpIssuer: requireHttpsUrl(rec.idpIssuer, `${where}.idpIssuer`),
|
|
215
|
+
idpClientId: requireString(rec.idpClientId, `${where}.idpClientId`),
|
|
216
|
+
idpClientSecretRef: requireString(rec.idpClientSecretRef, `${where}.idpClientSecretRef`),
|
|
217
|
+
idpClientSecretBroker: rec.idpClientSecretBroker === undefined ? "db" : requireString(rec.idpClientSecretBroker, `${where}.idpClientSecretBroker`),
|
|
218
|
+
audience: requireString(rec.audience, `${where}.audience`),
|
|
219
|
+
...(resource ? { resource } : {}),
|
|
220
|
+
resourceTokenEndpoint: requireHttpsUrl(rec.resourceTokenEndpoint, `${where}.resourceTokenEndpoint`),
|
|
221
|
+
resourceClientId: requireString(rec.resourceClientId, `${where}.resourceClientId`),
|
|
222
|
+
resourceClientSecretRef: requireString(rec.resourceClientSecretRef, `${where}.resourceClientSecretRef`),
|
|
223
|
+
resourceClientSecretBroker:
|
|
224
|
+
rec.resourceClientSecretBroker === undefined ? "db" : requireString(rec.resourceClientSecretBroker, `${where}.resourceClientSecretBroker`),
|
|
225
|
+
...(scopes ? { scopes } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
165
229
|
/** Parse ONE `discovery-dcr-pkce` entry. */
|
|
166
230
|
function parseDiscoveryProvider(ref: string, rec: Record<string, unknown>): OeOauthDiscoveryProviderConfig {
|
|
167
231
|
const where = `sign-in "${ref}"`;
|
|
@@ -192,9 +256,13 @@ function parseDeclarations(declared: ReadonlyMap<string, unknown>): Map<string,
|
|
|
192
256
|
out.set(ref, parseStaticProvider(ref, rec));
|
|
193
257
|
continue;
|
|
194
258
|
}
|
|
259
|
+
if (mode === OE_OAUTH_MODE_XAA) {
|
|
260
|
+
out.set(ref, parseXaaProvider(ref, rec));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
195
263
|
if (mode !== OE_OAUTH_MODE_DISCOVERY) {
|
|
196
264
|
throw new Error(
|
|
197
|
-
`sign-in "${ref}".mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}" or "${
|
|
265
|
+
`sign-in "${ref}".mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}", "${OE_OAUTH_MODE_STATIC}" or "${OE_OAUTH_MODE_XAA}".`,
|
|
198
266
|
);
|
|
199
267
|
}
|
|
200
268
|
const parsed = parseDiscoveryProvider(ref, rec);
|
package/agent/oe-oauth/errors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// DUCK-TYPED across processes. SECURITY: no error here carries a token, a code, or a PKCE verifier.
|
|
3
3
|
|
|
4
4
|
/** The subject an error is about, as a log-safe label (never an opaque token). */
|
|
5
|
-
|
|
5
|
+
function subjectLabel(subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }): string {
|
|
6
6
|
return subject.type === "app" ? "app" : `user:${subject.id}`;
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -40,6 +40,23 @@ export class OeOauthRefreshContendedError extends Error {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Cross App Access (OPE-2453): the IdP or the app REFUSED the mint for a person who is signed in — the IdP admin
|
|
44
|
+
* removed the pairing, unassigned the person, or never granted this app. A new sign-in link cannot fix that, so
|
|
45
|
+
* this error is DELIBERATELY not classified as user-auth-required: the person gets no "Connect my account" link,
|
|
46
|
+
* only the reason. Carries the RFC 6749 error code, never token material. */
|
|
47
|
+
export class OeOauthAccessRemovedError extends Error {
|
|
48
|
+
readonly code = "oe_oauth_access_removed";
|
|
49
|
+
readonly oauthError?: string;
|
|
50
|
+
constructor(providerRef: string, subject: { readonly type: "user"; readonly id: string } | { readonly type: "app" }, where: "idp" | "app", oauthError?: string) {
|
|
51
|
+
super(
|
|
52
|
+
`connection "${providerRef}": ${subjectLabel(subject)} — the ${where === "idp" ? "identity provider" : "app"} refused access` +
|
|
53
|
+
`${oauthError ? ` (${oauthError})` : ""}. Your identity-provider admin manages this access; a new sign-in will not restore it.`,
|
|
54
|
+
);
|
|
55
|
+
this.name = "OeOauthAccessRemovedError";
|
|
56
|
+
if (oauthError) this.oauthError = oauthError;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** A generic upstream OAuth failure (token endpoint / discovery / DCR returned an error). `oauthError`
|
|
44
61
|
* is the RFC 6749 `error` code when the body carried one (e.g. "invalid_grant", "invalid_client") —
|
|
45
62
|
* a fixed-vocabulary string, never a secret. */
|
|
@@ -21,6 +21,8 @@ export interface DiscoveredEndpoints {
|
|
|
21
21
|
/** A token endpoint response (authorization-code exchange or refresh). Only the fields we persist. */
|
|
22
22
|
export interface OAuthTokenResponse {
|
|
23
23
|
readonly access_token: string;
|
|
24
|
+
/** OIDC: present when the request asked for `openid` (the Cross App Access sign-in, OPE-2453). */
|
|
25
|
+
readonly id_token?: string;
|
|
24
26
|
readonly refresh_token?: string;
|
|
25
27
|
readonly expires_in?: number;
|
|
26
28
|
readonly scope?: string;
|
|
@@ -301,11 +303,16 @@ async function postTokenConfidential(
|
|
|
301
303
|
*/
|
|
302
304
|
export async function exchangeCodeConfidential(
|
|
303
305
|
tokenEndpoint: string,
|
|
304
|
-
input: { readonly code: string; readonly redirectUri: string },
|
|
306
|
+
input: { readonly code: string; readonly redirectUri: string; readonly codeVerifier?: string },
|
|
305
307
|
client: ConfidentialClient,
|
|
306
308
|
fetchImpl: OAuthFetch,
|
|
307
309
|
): Promise<OAuthTokenResponse> {
|
|
308
|
-
const res = await postTokenConfidential(
|
|
310
|
+
const res = await postTokenConfidential(
|
|
311
|
+
tokenEndpoint,
|
|
312
|
+
client,
|
|
313
|
+
{ grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri, ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}) },
|
|
314
|
+
fetchImpl,
|
|
315
|
+
);
|
|
309
316
|
return readTokenResponse(tokenEndpoint, res);
|
|
310
317
|
}
|
|
311
318
|
|
|
@@ -374,3 +381,112 @@ export function refreshToken(
|
|
|
374
381
|
fetchImpl,
|
|
375
382
|
);
|
|
376
383
|
}
|
|
384
|
+
|
|
385
|
+
// ── Cross App Access (OPE-2453): OIDC sign-in at the IdP, then the two ID-JAG legs ─────────────────────────────
|
|
386
|
+
|
|
387
|
+
/** RFC 8693 / ID-JAG wire constants. The draft is `draft-ietf-oauth-identity-assertion-authz-grant-04`, so these
|
|
388
|
+
* names can still move before an RFC; each lives here once. */
|
|
389
|
+
export const GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
390
|
+
export const GRANT_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
391
|
+
export const TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag";
|
|
392
|
+
export const TOKEN_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token";
|
|
393
|
+
|
|
394
|
+
/** The IdP endpoints an OIDC discovery document names. */
|
|
395
|
+
export interface OidcEndpoints {
|
|
396
|
+
readonly authorizationEndpoint: string;
|
|
397
|
+
readonly tokenEndpoint: string;
|
|
398
|
+
readonly jwksUri: string;
|
|
399
|
+
readonly revocationEndpoint?: string;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* OIDC discovery at `issuer` (OpenID Connect Discovery §4). The document's `issuer` MUST equal the configured one
|
|
404
|
+
* (§4.3): a mismatch means the document is not this IdP's, so it is refused. Every endpoint must be https.
|
|
405
|
+
*/
|
|
406
|
+
export async function discoverOidc(issuer: string, fetchImpl: OAuthFetch): Promise<OidcEndpoints> {
|
|
407
|
+
const url = `${trimSlash(issuer)}/.well-known/openid-configuration`;
|
|
408
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" }, redirect: "error" });
|
|
409
|
+
if (!res.ok) throw new OeOauthError(`OIDC discovery failed for ${issuer}`, { httpStatus: res.status, ...(await readError(res)) });
|
|
410
|
+
const doc = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
411
|
+
if (doc["issuer"] !== issuer) throw new OeOauthError(`OIDC discovery for ${issuer} names a different issuer — refused`);
|
|
412
|
+
const https = (k: string): string | undefined => {
|
|
413
|
+
const v = doc[k];
|
|
414
|
+
if (typeof v !== "string" || v === "") return undefined;
|
|
415
|
+
if (!v.startsWith("https://")) throw new OeOauthError(`OIDC discovery for ${issuer}: ${k} is not https — refused`);
|
|
416
|
+
return v;
|
|
417
|
+
};
|
|
418
|
+
const authorizationEndpoint = https("authorization_endpoint");
|
|
419
|
+
const tokenEndpoint = https("token_endpoint");
|
|
420
|
+
const jwksUri = https("jwks_uri");
|
|
421
|
+
const revocationEndpoint = https("revocation_endpoint");
|
|
422
|
+
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
|
|
423
|
+
throw new OeOauthError(`OIDC discovery for ${issuer} is missing authorization_endpoint, token_endpoint or jwks_uri`);
|
|
424
|
+
}
|
|
425
|
+
return { authorizationEndpoint, tokenEndpoint, jwksUri, ...(revocationEndpoint ? { revocationEndpoint } : {}) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Fetch the IdP's JWKS document — the keys an id token must verify against. */
|
|
429
|
+
export async function fetchJwks(jwksUri: string, fetchImpl: OAuthFetch): Promise<{ keys: Record<string, unknown>[] }> {
|
|
430
|
+
const res = await fetchImpl(jwksUri, { headers: { accept: "application/json" }, redirect: "error" });
|
|
431
|
+
if (!res.ok) throw new OeOauthError(`JWKS fetch failed at ${jwksUri}`, { httpStatus: res.status });
|
|
432
|
+
const doc = (await res.json().catch(() => ({}))) as { keys?: unknown };
|
|
433
|
+
if (!Array.isArray(doc.keys)) throw new OeOauthError(`JWKS at ${jwksUri} carries no keys array`);
|
|
434
|
+
return { keys: doc.keys as Record<string, unknown>[] };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* LEG 1 — RFC 8693 token exchange at the IdP: the person's IdP refresh token for an ID-JAG naming `audience`. OE
|
|
439
|
+
* authenticates as its IdP client (HTTP Basic). The ID-JAG comes back in `access_token`, and the response must
|
|
440
|
+
* say `issued_token_type` is an ID-JAG: anything else is not the grant leg 2 expects.
|
|
441
|
+
*/
|
|
442
|
+
export async function requestIdJag(
|
|
443
|
+
tokenEndpoint: string,
|
|
444
|
+
input: { readonly subjectToken: string; readonly audience: string; readonly resource?: string; readonly scopes?: readonly string[] },
|
|
445
|
+
client: ConfidentialClient,
|
|
446
|
+
fetchImpl: OAuthFetch,
|
|
447
|
+
): Promise<string> {
|
|
448
|
+
const res = await postTokenConfidential(
|
|
449
|
+
tokenEndpoint,
|
|
450
|
+
client,
|
|
451
|
+
{
|
|
452
|
+
grant_type: GRANT_TOKEN_EXCHANGE,
|
|
453
|
+
requested_token_type: TOKEN_TYPE_ID_JAG,
|
|
454
|
+
subject_token: input.subjectToken,
|
|
455
|
+
subject_token_type: TOKEN_TYPE_REFRESH_TOKEN,
|
|
456
|
+
audience: input.audience,
|
|
457
|
+
...(input.resource ? { resource: input.resource } : {}),
|
|
458
|
+
...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
|
|
459
|
+
},
|
|
460
|
+
fetchImpl,
|
|
461
|
+
);
|
|
462
|
+
const body = (await readTokenResponse(tokenEndpoint, res)) as OAuthTokenResponse & { issued_token_type?: unknown };
|
|
463
|
+
if (body.issued_token_type !== TOKEN_TYPE_ID_JAG) {
|
|
464
|
+
throw new OeOauthError(`token endpoint ${tokenEndpoint} answered the ID-JAG exchange with another token type — refused`);
|
|
465
|
+
}
|
|
466
|
+
return body.access_token;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* LEG 2 — RFC 7523 JWT bearer grant at the APP's authorization server: the ID-JAG for the app's access token. OE
|
|
471
|
+
* authenticates with the client the APP issued it (HTTP Basic); the app checks the ID-JAG's `client_id` against
|
|
472
|
+
* it. The draft says the app SHOULD NOT return a refresh token, so the caller re-mints at expiry.
|
|
473
|
+
*/
|
|
474
|
+
export async function jwtBearerGrant(
|
|
475
|
+
tokenEndpoint: string,
|
|
476
|
+
input: { readonly assertion: string; readonly resource?: string; readonly scopes?: readonly string[] },
|
|
477
|
+
client: ConfidentialClient,
|
|
478
|
+
fetchImpl: OAuthFetch,
|
|
479
|
+
): Promise<OAuthTokenResponse> {
|
|
480
|
+
const res = await postTokenConfidential(
|
|
481
|
+
tokenEndpoint,
|
|
482
|
+
client,
|
|
483
|
+
{
|
|
484
|
+
grant_type: GRANT_JWT_BEARER,
|
|
485
|
+
assertion: input.assertion,
|
|
486
|
+
...(input.resource ? { resource: input.resource } : {}),
|
|
487
|
+
...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
|
|
488
|
+
},
|
|
489
|
+
fetchImpl,
|
|
490
|
+
);
|
|
491
|
+
return readTokenResponse(tokenEndpoint, res);
|
|
492
|
+
}
|