diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index afcc16a70db..dec04499970 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1474,9 +1474,10 @@ jobs: # localhost-compatible routing, namespace-aware HTTPS pinning, DNS # rebinding resistance, private-target redirect rejection, and # credential-bearing URL state rejection without live provider quota. - # It also proves runtime identity through a deterministic public OAuth - # refresh, real OpenShell provider attachment/injection, rotation, and - # rollback without using a third-party tenant. + # It also proves generic and Entra-shaped runtime identity through a + # deterministic public OAuth refresh, real OpenShell provider + # attachment/injection, Graph /v1.0/me policy, rotation, and rollback + # without storing a third-party tenant credential in PR CI. # Provider smokes live in inference-routing-provider-smoke.test.ts; any # future secret-bearing lane must run that file from trusted main. run: | diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 49f7a29cc5c..dd64dff5788 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -262,14 +262,14 @@ flowchart LR The direct OpenClaw blueprint runner can opt in to a provider-neutral runtime identity component. The Hermes manifest onboarding path does not consume this component. This experimental reference capability is not enabled by the shipped blueprint, and normal `$$nemoclaw onboard` does not collect or provision its inputs. -The bundled Okta profile is the first data-only implementation of the component. +The bundled Okta and Microsoft Entra profiles are data-only implementations of the same component. The blueprint schema describes the OpenShell provider binding and OAuth refresh inputs without using the identity provider as a schema discriminator. Runtime identity profiles may use DNS-backed HTTPS only within a provider type's repository-reviewed hostname suffixes. NemoClaw resolves every destination before import and rejects private or internal addresses. OpenShell performs connect-time SSRF and L7 enforcement for sandbox requests before it injects the provider credential. - OpenShell 0.0.85's gateway-side OAuth refresh client enforces HTTPS and verifies the original hostname's certificate but does not pin the address NemoClaw resolved, so only identity-platform-controlled DNS suffixes such as `okta.com` belong in this trust table. + OpenShell 0.0.85's gateway-side OAuth refresh client enforces HTTPS and verifies the original hostname's certificate but does not pin the address NemoClaw resolved, so only identity-platform-controlled DNS namespaces such as `okta.com`, `login.microsoftonline.com`, and `graph.microsoft.com` belong in this trust table. Adding an attacker-controlled or customer-controlled DNS suffix requires a pinning-capable upstream refresh boundary and new conformance evidence. @@ -281,11 +281,17 @@ The blueprint, copied profile, same-name gateway resources, sandbox workload, su The runner therefore validates the complete data-only profile before import, fails closed on ambiguous resource inspection, scopes secret material to the single refresh-configuration subprocess, and persists only non-secret ownership receipts. OpenShell remains responsible for credential custody, refresh, admitted-request enforcement, and bearer substitution; NemoClaw never exposes the minted bearer to the sandbox. -The deterministic `TC-INF-12` protected E2E scenario is the conformance gate for this boundary. -It runs the real blueprint runner against a real OpenShell gateway and sandbox, enables provider-derived policy for the test and restores its prior setting, exchanges a refresh token through a public HTTPS OAuth endpoint, proves the sandbox sees only opaque placeholders, proves a child launched after rotation receives a different revision-scoped placeholder, proves the protected resource receives the first and rotated bearers, checks secret-free plan, status, state, logs, and request ledgers, and verifies ownership-aware rollback. +The deterministic `TC-INF-12` and `TC-INF-13` protected E2E scenarios are the conformance gates for this boundary. +Both run the real blueprint runner against a real OpenShell gateway and sandbox, enable provider-derived policy for the test and restore its prior setting, exchange a refresh token through a public HTTPS OAuth endpoint, prove the sandbox sees only opaque placeholders, prove a child launched after rotation receives a different revision-scoped placeholder, prove the protected resource receives the first and rotated bearers, check secret-free plan, status, state, logs, and request ledgers, and verify ownership-aware rollback. +`TC-INF-13` additionally uses the Entra v2 token-path shape, `ENTRA_ACCESS_TOKEN`, and the exact Microsoft Graph `GET /v1.0/me` credential-delivery rule, then proves a `GET /v1.0/users` request is rejected before its bearer reaches the fixture. +The deterministic fixture proves the complete Entra-shaped runtime path without storing a third-party tenant credential in pull-request CI; tenant acceptance remains a maintainer-run check against Microsoft Entra and Graph. Focused runner and runtime-identity tests cover malformed profiles, endpoint and DNS rejection, subprocess scoping, same-name resource handling, partial-apply compensation, and retryable rollback receipts. The gateway refresh client's connect-time DNS-pinning limitation described above is the accepted residual boundary; expanding the trusted hostname policy requires upstream pinning and new conformance evidence. + +Hermes and LangChain Deep Agents Code have no equivalent runtime identity operation because their manifest onboarding paths do not consume this experimental component. + + Enable provider-derived policy on the target gateway before applying a blueprint that attaches this component: ```bash @@ -295,6 +301,8 @@ openshell settings set --global --key providers_v2_enabled --value true --yes Apply reads the gateway-global setting before identity mutation and again immediately before attachment, and stops unless its JSON value is exactly `true`. This prevents a successful-looking attachment whose provider-derived network policy and credential injection are inactive. +#### Okta Reference + Copy `nemoclaw-blueprint/provider-profiles/okta-runtime-v1.yaml` into the blueprint you operate as `provider-profiles/acme-okta-runtime.yaml`. You can choose another tenant-specific filename, but set `profile_path` below to that exact relative path. Set its `token_url` to the tenant's authorization-server token endpoint. @@ -330,6 +338,61 @@ Before applying the blueprint, export the named environment variables in the hos Obtain the refresh token through an authorized OAuth bootstrap flow for the same Okta client and authorization server as the profile. `openshell gateway login` authenticates a CLI user to the gateway and is not part of this runtime-credential flow. +#### Microsoft Entra Reference + +Copy `nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml` into the operated blueprint as `provider-profiles/acme-entra-runtime.yaml`. +Replace `organizations` in `token_url` with the Directory (tenant) ID for the application registration. +The built-in policy accepts only the exact `login.microsoftonline.com` token issuer and `graph.microsoft.com` resource host; it does not admit national-cloud endpoints or tenant-controlled custom domains. +Bearer delivery is restricted to exactly `GET /v1.0/me`. + +Configure the same provider-neutral component with the Entra implementation: + +```yaml +identity: + profile_path: provider-profiles/acme-entra-runtime.yaml + provider_type: entra-runtime-v1 + provider_name: acme-entra-runtime + credential_key: ENTRA_ACCESS_TOKEN + client_id_env: ENTRA_CLIENT_ID + refresh_token_env: ENTRA_REFRESH_TOKEN + client_secret_env: ENTRA_CLIENT_SECRET +``` + +Register a separate Entra application for the delegated runtime flow. +Grant only the Microsoft Graph delegated `User.Read` permission needed by [`GET /me`](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0), and request [`offline_access`](https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#the-offline_access-scope) during the external authorization-code bootstrap so the v2 endpoint returns a refresh token. +Microsoft documents `offline_access` as an explicit requirement for v2 refresh-token issuance and [documents replacement of the stored refresh token](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token) when a refresh response carries a new one. +Use a confidential web application and `ENTRA_CLIENT_SECRET` when the bootstrap client can keep a secret; omit `client_secret_env` for a public client rather than placing a public-client secret on the host. +NemoClaw does not package the authorization redirect, consent UI, PKCE exchange, or MSAL cache. + + + The host process can read `ENTRA_REFRESH_TOKEN` and `ENTRA_CLIENT_SECRET`, and the runner forwards them only to the scoped refresh-configuration subprocess. + These environment variables remain available until you unset them or the host process exits. + After apply succeeds, run `unset ENTRA_REFRESH_TOKEN ENTRA_CLIENT_SECRET` in the host shell. + OpenShell retains the refresh material in the gateway credential store until rollback or provider deletion removes the owned provider. + + +Acquire the initial refresh token outside NemoClaw using Microsoft-supported identity tooling, then export the application client ID, refresh token, and optional client secret into the host process that runs the direct blueprint runner. + +The repository does not publish a user-facing command for this experimental direct runner. +A maintainer-run tenant acceptance must establish all of the following criteria: + +- Apply imports the copied Entra profile, creates the owned provider, attaches it to the intended sandbox, and reports successful refresh status. +- A newly launched sandbox child sees an `openshell:resolve:env:...` placeholder instead of the access token. +- The protected-resource request below returns the signed-in delegated user. +- OpenShell rejects a request to another Graph path before bearer delivery. +- Credential rotation gives a new child a new revision-scoped placeholder, and `GET /v1.0/me` succeeds with the rotated credential. +- Rollback removes the owned attachment and provider while preserving any reused sandbox. + +Do not save the Graph response, refresh token, access token, or client secret in a terminal transcript or CI artifact. +Run the protected-resource acceptance request from a newly launched sandbox child, where `ENTRA_ACCESS_TOKEN` is the child-scoped OpenShell placeholder. +Do not run this request from the host shell; the child request must traverse the OpenShell proxy so its bearer delivery and Graph path restrictions are exercised: + +```bash +curl -fsS \ + -H "Authorization: Bearer ${ENTRA_ACCESS_TOKEN}" \ + https://graph.microsoft.com/v1.0/me +``` + During plan, the runner reports only the non-secret provider type, provider name, and credential key. During apply, the runner first inspects the target sandbox and stops before identity mutation unless it confirms the exact name and `Ready` phase or an explicit sandbox-not-found result. If sandbox creation races with another creator, the runner repeats the same exact-name and `Ready` inspection before continuing. @@ -351,7 +414,7 @@ If a later apply step fails, the runner detaches the runtime identity provider w The runner never places the refresh token or optional client secret in command arguments or persisted plans. It passes those values to `openshell provider refresh configure` through a scoped subprocess environment, and OpenShell stores the resulting credential material in the gateway credential store. All other runner subprocesses receive the allowlisted environment without the identity material. -Each sandbox child launch receives an opaque `OKTA_ACCESS_TOKEN` placeholder, and the OpenShell L7 proxy substitutes its corresponding access token only for admitted HTTPS requests. +Each sandbox child launch receives an opaque provider-specific placeholder such as `OKTA_ACCESS_TOKEN` or `ENTRA_ACCESS_TOKEN`, and the OpenShell L7 proxy substitutes its corresponding access token only for admitted HTTPS requests. After a credential rotation, launch a new child process to receive the new revision-scoped placeholder instead of expecting an earlier child launch to adopt the rotation. Check the gateway-side state without printing credential values: @@ -371,6 +434,7 @@ Rollback stops without mutating a same-name provider when the binding no longer Runtime identity does not add a generic blueprint middleware surface in this slice. Configure any deployment-specific pre-credential policy through separately supported OpenShell tooling. This reference does not package an OAuth bootstrap application, an on-behalf-of exchange, or a production identity middleware service. + ## Sandbox Environment diff --git a/nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml b/nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml new file mode 100644 index 00000000000..f7fbbb1b27b --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Data-only reference for the experimental runtime identity contract. +# The direct runner validates this complete credential-delivery shape before +# import, including the Microsoft-controlled destinations, exact executable +# allowlist, and Graph /v1.0/me endpoint rule. OpenShell owns refresh-token +# custody, access-token rotation, sandbox placeholders, and post-policy +# injection. Copy this file and replace "organizations" in the token URL with +# the Microsoft Entra tenant ID used by the delegated application. +id: entra-runtime-v1 +display_name: Microsoft Entra Runtime Credentials v1 +description: Gateway-managed Microsoft Graph access-token refresh for an attached sandbox +category: agent +credentials: + - name: ENTRA_ACCESS_TOKEN + description: Short-lived delegated Microsoft Graph access token + env_vars: + - ENTRA_ACCESS_TOKEN + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: https://login.microsoftonline.com/organizations/oauth2/v2.0/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: client_id + required: true + - name: refresh_token + required: true + secret: true + - name: client_secret + required: false + secret: true +endpoints: + - host: graph.microsoft.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/v1.0/me" } +binaries: + - /usr/local/bin/node + - /usr/bin/node + - /usr/local/bin/curl + - /usr/bin/curl +inference_capable: false diff --git a/nemoclaw/src/blueprint/runtime-identity.test.ts b/nemoclaw/src/blueprint/runtime-identity.test.ts index 2f34b67f637..3f975f03127 100644 --- a/nemoclaw/src/blueprint/runtime-identity.test.ts +++ b/nemoclaw/src/blueprint/runtime-identity.test.ts @@ -101,6 +101,10 @@ const profileDocument = [ "inference_capable: false", "", ].join("\n"); +const entraProfileDocument = readFileSync( + new URL("../../../nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml", import.meta.url), + "utf8", +); const config: RuntimeIdentityConfig = { profile_path: "provider-profiles/okta-runtime-v1.yaml", @@ -111,6 +115,15 @@ const config: RuntimeIdentityConfig = { refresh_token_env: "OKTA_REFRESH_TOKEN", client_secret_env: "OKTA_CLIENT_SECRET", }; +const entraConfig: RuntimeIdentityConfig = { + profile_path: "provider-profiles/entra-runtime-v1.yaml", + provider_type: "entra-runtime-v1", + provider_name: "acme-entra-runtime", + credential_key: "ENTRA_ACCESS_TOKEN", + client_id_env: "ENTRA_CLIENT_ID", + refresh_token_env: "ENTRA_REFRESH_TOKEN", + client_secret_env: "ENTRA_CLIENT_SECRET", +}; const createdReceipt: RuntimeIdentityReceipt = { provider_type: config.provider_type, @@ -473,6 +486,68 @@ describe("runtime identity contract", () => { expect(calls.map(({ args }) => commandKey(args))).toContain("provider profile import --file"); }); + it("accepts the bundled Entra profile and scopes bearer delivery to Graph me", async () => { + writeFileSync(join(root, entraConfig.profile_path), entraProfileDocument); + environment.ENTRA_CLIENT_ID = "entra-client-id"; + environment.ENTRA_REFRESH_TOKEN = "entra-refresh-secret"; + environment.ENTRA_CLIENT_SECRET = "entra-client-secret"; + responses.set("provider get acme-entra-runtime", [missingProvider]); + + await expect(prepareRuntimeIdentity(entraConfig, deps)).resolves.toMatchObject({ + provider_type: "entra-runtime-v1", + provider_name: "acme-entra-runtime", + credential_key: "ENTRA_ACCESS_TOKEN", + provider_created: true, + }); + + expect(validatedDestinations).toEqual([ + "https://login.microsoftonline.com/organizations/oauth2/v2.0/token", + "https://graph.microsoft.com/", + ]); + expect(calls.map(({ args }) => commandKey(args))).toContain( + "provider refresh configure acme-entra-runtime --credential-key ENTRA_ACCESS_TOKEN " + + "--strategy oauth2-refresh-token --material client_id=entra-client-id " + + "--secret-material-env refresh_token=ENTRA_REFRESH_TOKEN " + + "--secret-material-env client_secret=ENTRA_CLIENT_SECRET", + ); + expect(importedProfilePaths).toHaveLength(1); + expect(existsSync(importedProfilePaths[0])).toBe(false); + }); + + it.each([ + [ + "credential-delivery path", + entraProfileDocument.replace( + ' - allow: { method: GET, path: "/v1.0/me" }', + ' - allow: { method: GET, path: "/**" }', + ), + /REST GET \/v1\.0\/me credential-delivery policy/, + ], + [ + "credential-delivery host", + entraProfileDocument.replace("host: graph.microsoft.com", "host: login.microsoftonline.com"), + /credential delivery host 'login\.microsoftonline\.com' is outside/, + ], + [ + "credential-delivery hostname suffix", + entraProfileDocument.replace("graph.microsoft.com", "graph.microsoft.com.attacker.example"), + /outside the trusted destination policy/, + ], + [ + "token-issuer host", + entraProfileDocument.replace("login.microsoftonline.com", "graph.microsoft.com"), + /refresh token_url host 'graph\.microsoft\.com' is outside/, + ], + ])("rejects an Entra profile that changes the reviewed %s", async (_boundary, profile, message) => { + writeFileSync(join(root, entraConfig.profile_path), profile); + environment.ENTRA_CLIENT_ID = "entra-client-id"; + environment.ENTRA_REFRESH_TOKEN = "entra-refresh-secret"; + environment.ENTRA_CLIENT_SECRET = "entra-client-secret"; + + await expect(prepareRuntimeIdentity(entraConfig, deps)).rejects.toThrow(message); + expect(calls).toEqual([]); + }); + it("rejects DNS-backed destinations unless the reviewed profile policy owns DNS", async () => { deps.validateEndpointUrl = async () => ({ dnsResolved: true }); @@ -483,8 +558,16 @@ describe("runtime identity contract", () => { providerType: "okta-runtime-v1", clientIdEnvironmentName: "OKTA_CLIENT_ID", dnsResolution: "reject", - trustedHostnames: [], - trustedHostSuffixes: ["okta.com"], + tokenIssuer: { + trustedHostnames: [], + trustedHostSuffixes: ["okta.com"], + }, + credentialDelivery: { + method: "GET", + path: "/**", + trustedHostnames: [], + trustedHostSuffixes: ["okta.com"], + }, trustedBinaries: [ "/usr/local/bin/node", "/usr/bin/node", @@ -534,8 +617,16 @@ describe("runtime identity contract", () => { providerType: "oauth2-runtime-conformance-v1", clientIdEnvironmentName: "E2E_CLIENT_ID", dnsResolution: "identity-platform-controlled", - trustedHostnames: ["identity-fixture.trycloudflare.com"], - trustedHostSuffixes: [], + tokenIssuer: { + trustedHostnames: ["identity-fixture.trycloudflare.com"], + trustedHostSuffixes: [], + }, + credentialDelivery: { + method: "GET", + path: "/**", + trustedHostnames: ["identity-fixture.trycloudflare.com"], + trustedHostSuffixes: [], + }, trustedBinaries: [ "/usr/local/bin/node", "/usr/bin/node", diff --git a/nemoclaw/src/blueprint/runtime-identity.ts b/nemoclaw/src/blueprint/runtime-identity.ts index 2efd09016f1..0a579d2a776 100644 --- a/nemoclaw/src/blueprint/runtime-identity.ts +++ b/nemoclaw/src/blueprint/runtime-identity.ts @@ -35,7 +35,7 @@ const MAX_PROFILE_BYTES = 64 * 1024; const MAX_PROFILE_ENDPOINTS = 32; const MAX_DESTINATION_URL_LENGTH = 2048; const PROVIDERS_V2_SETTING = "providers_v2_enabled"; -const OKTA_RUNTIME_BINARIES = Object.freeze([ +const RUNTIME_IDENTITY_BINARIES = Object.freeze([ "/usr/local/bin/node", "/usr/bin/node", "/usr/local/bin/curl", @@ -124,8 +124,16 @@ export interface RuntimeIdentityProfilePolicy { providerType: string; clientIdEnvironmentName: string; dnsResolution: "reject" | "identity-platform-controlled"; - trustedHostnames: readonly string[]; - trustedHostSuffixes: readonly string[]; + tokenIssuer: { + trustedHostnames: readonly string[]; + trustedHostSuffixes: readonly string[]; + }; + credentialDelivery: { + method: "GET"; + path: string; + trustedHostnames: readonly string[]; + trustedHostSuffixes: readonly string[]; + }; trustedBinaries: readonly string[]; } @@ -135,9 +143,33 @@ const RUNTIME_IDENTITY_PROFILE_POLICIES: Readonly + const trustedHostnames = trustedHostnamesInput.map((candidate) => candidate.toLowerCase().replace(/\.$/u, ""), ); - const trustedSuffixes = policy.trustedHostSuffixes.map((candidate) => + const trustedSuffixes = trustedHostSuffixesInput.map((candidate) => candidate.toLowerCase().replace(/\.$/u, ""), ); if ( @@ -238,14 +272,16 @@ function requireTrustedProfileHostname( !trustedSuffixes.some((suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`)) ) { throw new Error( - `${label} host '${hostname}' is outside the trusted destination policy for '${policy.providerType}'`, + `${label} host '${hostname}' is outside the trusted destination policy for '${providerType}'`, ); } } function requireHttpsDestination( value: string, - policy: RuntimeIdentityProfilePolicy, + trustedHostnames: readonly string[], + trustedHostSuffixes: readonly string[], + providerType: string, label: string, ): string { if (value.length === 0 || value.length > MAX_DESTINATION_URL_LENGTH) { @@ -263,7 +299,13 @@ function requireHttpsDestination( if (parsed.username !== "" || parsed.password !== "") { throw new Error(`${label} must not include URL credentials`); } - requireTrustedProfileHostname(parsed.hostname, policy, label); + requireTrustedProfileHostname( + parsed.hostname, + trustedHostnames, + trustedHostSuffixes, + providerType, + label, + ); return parsed.toString(); } @@ -292,6 +334,13 @@ function parseRuntimeIdentityEndpoint( if (unbracketedHost.includes("[") || unbracketedHost.includes("]")) { throw new Error(`${label} must declare a valid host`); } + requireTrustedProfileHostname( + unbracketedHost, + policy.credentialDelivery.trustedHostnames, + policy.credentialDelivery.trustedHostSuffixes, + policy.providerType, + `${label} credential delivery`, + ); if ( protocol !== "rest" || enforcement !== "enforce" || @@ -301,20 +350,36 @@ function parseRuntimeIdentityEndpoint( !hasOnlyKeys(rules[0], ["allow"]) || !isPlainObject(rules[0].allow) || !hasOnlyKeys(rules[0].allow, ["method", "path"]) || - rules[0].allow.method !== "GET" || - rules[0].allow.path !== "/**" + rules[0].allow.method !== policy.credentialDelivery.method || + rules[0].allow.path !== policy.credentialDelivery.path ) { - throw new Error(`${label} must enforce the reviewed REST GET /** credential-delivery policy`); + throw new Error( + `${label} must enforce the reviewed REST ${policy.credentialDelivery.method} ` + + `${policy.credentialDelivery.path} credential-delivery policy`, + ); } const urlHost = unbracketedHost.includes(":") ? `[${unbracketedHost}]` : unbracketedHost; return { - destination: requireHttpsDestination(`https://${urlHost}:${String(port)}/`, policy, label), + destination: requireHttpsDestination( + `https://${urlHost}:${String(port)}/`, + policy.credentialDelivery.trustedHostnames, + policy.credentialDelivery.trustedHostSuffixes, + policy.providerType, + label, + ), document: { host, port, protocol: "rest", enforcement: "enforce", - rules: [{ allow: { method: "GET", path: "/**" } }], + rules: [ + { + allow: { + method: policy.credentialDelivery.method, + path: policy.credentialDelivery.path, + }, + }, + ], }, }; } @@ -387,7 +452,13 @@ function parseRuntimeIdentityProfile( throw new Error(`${label} must declare a refresh token_url`); } const material = requireExactRefreshMaterial(refresh.material, `${label} refresh material`); - const tokenUrl = requireHttpsDestination(refresh.token_url, policy, `${label} refresh token_url`); + const tokenUrl = requireHttpsDestination( + refresh.token_url, + policy.tokenIssuer.trustedHostnames, + policy.tokenIssuer.trustedHostSuffixes, + policy.providerType, + `${label} refresh token_url`, + ); const endpoints = parsed.endpoints; if ( !Array.isArray(endpoints) || diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index 6e5143487c1..ee958ed60f5 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -9,7 +9,7 @@ import { HTTPS_PIN_RUNTIME_ADAPTER_BASE_ORIGIN } from "../../../src/lib/inferenc import { REGISTRY_FILE, type SandboxEntry } from "../../../src/lib/state/registry.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText } from "../fixtures/clients/command.ts"; -import { expect, test } from "../fixtures/e2e-test.ts"; +import { type E2ETargetFixtures, expect, test } from "../fixtures/e2e-test.ts"; import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import { resolveVerifiedCloudflaredBinary } from "./cloudflared-prerequisite.ts"; @@ -245,7 +245,68 @@ await main(["apply"]); expect(openshellLog).toBe(""); }); -test("TC-INF-12 runtime identity refreshes and injects a delegated bearer through real OpenShell", { +interface RuntimeIdentityE2EScenario { + readonly testId: "TC-INF-12" | "TC-INF-13"; + readonly providerType: string; + readonly credentialKey: string; + readonly clientIdEnvironmentName: string; + readonly refreshTokenEnvironmentName: string; + readonly clientSecretEnvironmentName: string; + readonly tokenPath: string; + readonly resourcePath: string; + readonly reviewedResourcePath: string; + readonly deniedMethod: "GET" | "POST"; + readonly deniedPath: string; + readonly targetId: string; +} + +const RUNTIME_IDENTITY_E2E_SCENARIOS = [ + [ + "12", + "", + { + testId: "TC-INF-12", + providerType: "oauth2-runtime-conformance-v1", + credentialKey: "E2E_ACCESS_TOKEN", + clientIdEnvironmentName: "E2E_CLIENT_ID", + refreshTokenEnvironmentName: "E2E_REFRESH_TOKEN", + clientSecretEnvironmentName: "E2E_CLIENT_SECRET", + tokenPath: "/oauth/token", + resourcePath: "/resource", + reviewedResourcePath: "/**", + deniedMethod: "POST", + deniedPath: "/resource", + targetId: "runtime-identity-reference-real-oauth-lifecycle", + }, + ], + [ + "13", + "Entra Graph ", + { + testId: "TC-INF-13", + providerType: "entra-runtime-v1", + credentialKey: "ENTRA_ACCESS_TOKEN", + clientIdEnvironmentName: "ENTRA_CLIENT_ID", + refreshTokenEnvironmentName: "ENTRA_REFRESH_TOKEN", + clientSecretEnvironmentName: "ENTRA_CLIENT_SECRET", + tokenPath: "/organizations/oauth2/v2.0/token", + resourcePath: "/v1.0/me", + reviewedResourcePath: "/v1.0/me", + deniedMethod: "GET", + deniedPath: "/v1.0/users", + targetId: "entra-runtime-identity-real-oauth-lifecycle", + }, + ], +] as const satisfies readonly (readonly [string, string, RuntimeIdentityE2EScenario])[]; + +type RuntimeIdentityE2EContext = Pick< + E2ETargetFixtures, + "artifacts" | "cleanup" | "host" | "progress" | "sandbox" +> & { + skip: (note?: string) => never; +}; + +const RUNTIME_IDENTITY_E2E_OPTIONS = { timeout: 20 * 60_000, meta: { e2ePhases: [ @@ -256,11 +317,21 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug "apply and attach the runtime identity through OpenShell", "prove inference remains live after identity attachment", "call the protected resource with the injected bearer", + "reject unreviewed credential delivery before bearer substitution", "rotate the credential and relaunch with its new placeholder", "verify secret-safe status and deterministic rollback", ], }, -}, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { +} as const; + +async function runRuntimeIdentityE2EScenario( + _testNumber: string, + _providerLabel: string, + scenario: RuntimeIdentityE2EScenario, + context: RuntimeIdentityE2EContext, +): Promise { + const { artifacts, cleanup, host, progress, sandbox, skip } = context; + const artifactPrefix = scenario.testId.toLowerCase(); progress.phase("confirm live runtime identity prerequisites"); await requireLivePrerequisites(host, skip); const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-identity-e2e-")); @@ -273,10 +344,10 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug const model = "nemoclaw-e2e-runtime-identity"; const inferenceKey = "sk-runtime-identity-TEST-NOT-A-REAL-VALUE"; - const sandboxName = inferenceSandboxName("e2e-runtime-id"); - const providerType = "oauth2-runtime-conformance-v1"; - const providerName = `e2e-oauth-runtime-${String(process.pid)}`; - const credentialKey = "E2E_ACCESS_TOKEN"; + const sandboxName = inferenceSandboxName(`e2e-${scenario.testId.toLowerCase()}`); + const providerType = scenario.providerType; + const providerName = `e2e-${scenario.providerType}-${String(process.pid)}`; + const credentialKey = scenario.credentialKey; const clientId = "e2e-runtime-identity-client-id"; const refreshToken = "e2e-runtime-identity-refresh-token-v1"; const clientSecret = "e2e-runtime-identity-client-secret"; @@ -303,7 +374,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug }); cleanup.add("close runtime identity inference prerequisite", async () => { try { - await artifacts.writeJson("tc-inf-12-inference-requests.json", inference.requests()); + await artifacts.writeJson(`${artifactPrefix}-inference-requests.json`, inference.requests()); } finally { await inference.close(); } @@ -321,11 +392,11 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug NEMOCLAW_PROVIDER: "custom", }, [inferenceKey], - "tc-inf-12-onboard-real-openshell-sandbox", + `${artifactPrefix}-onboard-real-openshell-sandbox`, progress, 15 * 60_000, ); - expectOnboardSuccess(onboard, "TC-INF-12 real OpenShell prerequisite onboard"); + expectOnboardSuccess(onboard, `${scenario.testId} real OpenShell prerequisite onboard`); cleanup.add(`strict runtime identity sandbox cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName, { strict: true }), ); @@ -333,18 +404,18 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug // Remove stale fixture-owned objects left by a previously interrupted local // run. Both operations are best-effort and target only this E2E namespace. await sandbox.openshell(["provider", "delete", providerName], { - artifactName: "tc-inf-12-preclean-provider", + artifactName: `${artifactPrefix}-preclean-provider`, env: openshellEnv, timeoutMs: 30_000, }); await sandbox.openshell(["provider", "profile", "delete", providerType], { - artifactName: "tc-inf-12-preclean-profile", + artifactName: `${artifactPrefix}-preclean-profile`, env: openshellEnv, timeoutMs: 30_000, }); const settingsBefore = await sandbox.openshell(["settings", "get", "--global", "--json"], { - artifactName: "tc-inf-12-provider-policy-setting-before", + artifactName: `${artifactPrefix}-provider-policy-setting-before`, env: openshellEnv, timeoutMs: 30_000, }); @@ -367,7 +438,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug expect(restoreSettingArgs).toBeDefined(); cleanup.add("restore OpenShell provider-derived policy setting", async () => { const restored = await sandbox.openshell(restoreSettingArgs!, { - artifactName: "tc-inf-12-provider-policy-setting-restore", + artifactName: `${artifactPrefix}-provider-policy-setting-restore`, env: openshellEnv, timeoutMs: 30_000, }); @@ -376,7 +447,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug const enableProviderPolicy = await sandbox.openshell( ["settings", "set", "--global", "--key", "providers_v2_enabled", "--value", "true", "--yes"], { - artifactName: "tc-inf-12-provider-policy-setting-enable", + artifactName: `${artifactPrefix}-provider-policy-setting-enable`, env: openshellEnv, timeoutMs: 30_000, }, @@ -388,12 +459,17 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug clientId, clientSecret, initialRefreshToken: refreshToken, + resourcePath: scenario.resourcePath, + tokenPath: scenario.tokenPath, }); cleanup.add("close runtime identity OAuth fixture", async () => { try { - await artifacts.writeJson("tc-inf-12-oauth-token-requests.json", oauth.tokenRequests()); await artifacts.writeJson( - "tc-inf-12-protected-resource-requests.json", + `${artifactPrefix}-oauth-token-requests.json`, + oauth.tokenRequests(), + ); + await artifacts.writeJson( + `${artifactPrefix}-protected-resource-requests.json`, oauth.resourceRequests(), ); } finally { @@ -406,17 +482,25 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug cleanup, label: "runtime identity OAuth", progress, - readinessPath: "/resource", + readinessPath: scenario.resourcePath, readinessStatus: 401, server: oauth, }); const endpoint = new URL(tunnel.origin); const runtimeIdentityProfilePolicy = { providerType, - clientIdEnvironmentName: "E2E_CLIENT_ID", + clientIdEnvironmentName: scenario.clientIdEnvironmentName, dnsResolution: "identity-platform-controlled", - trustedHostnames: [endpoint.hostname], - trustedHostSuffixes: [], + tokenIssuer: { + trustedHostnames: [endpoint.hostname], + trustedHostSuffixes: [], + }, + credentialDelivery: { + method: "GET", + path: scenario.reviewedResourcePath, + trustedHostnames: [endpoint.hostname], + trustedHostSuffixes: [], + }, trustedBinaries: [ "/usr/local/bin/node", "/usr/bin/node", @@ -424,13 +508,14 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug "/usr/bin/curl", ], }; - const profilePath = path.join(profileDir, "oauth2-runtime-conformance-v1.yaml"); + const profileFilename = `${providerType}.yaml`; + const profilePath = path.join(profileDir, profileFilename); fs.writeFileSync( profilePath, [ `id: ${providerType}`, - "display_name: OAuth2 Runtime Identity Conformance v1", - "description: Deterministic OAuth refresh and bearer-injection conformance profile", + `display_name: ${scenario.testId} Runtime Identity Conformance`, + `description: Deterministic ${scenario.testId} OAuth refresh and bearer-injection conformance profile`, "category: agent", "credentials:", ` - name: ${credentialKey}`, @@ -442,7 +527,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug " header_name: authorization", " refresh:", " strategy: oauth2_refresh_token", - ` token_url: ${tunnel.origin}/oauth/token`, + ` token_url: ${tunnel.origin}${scenario.tokenPath}`, " refresh_before_seconds: 300", " max_lifetime_seconds: 3600", " material:", @@ -460,7 +545,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug " protocol: rest", " enforcement: enforce", " rules:", - ' - allow: { method: GET, path: "/**" }', + ` - allow: { method: GET, path: "${scenario.reviewedResourcePath}" }`, "binaries:", " - /usr/local/bin/node", " - /usr/bin/node", @@ -486,13 +571,13 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug " provider_name: compatible-endpoint", ` model: ${model}`, " identity:", - " profile_path: provider-profiles/oauth2-runtime-conformance-v1.yaml", + ` profile_path: provider-profiles/${profileFilename}`, ` provider_type: ${providerType}`, ` provider_name: ${providerName}`, ` credential_key: ${credentialKey}`, - " client_id_env: E2E_CLIENT_ID", - " refresh_token_env: E2E_REFRESH_TOKEN", - " client_secret_env: E2E_CLIENT_SECRET", + ` client_id_env: ${scenario.clientIdEnvironmentName}`, + ` refresh_token_env: ${scenario.refreshTokenEnvironmentName}`, + ` client_secret_env: ${scenario.clientSecretEnvironmentName}`, "", ].join("\n"), { mode: 0o600 }, @@ -503,19 +588,20 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug const tsxPath = path.join(REPO_ROOT, "node_modules/tsx/dist/cli.mjs"); const runnerEnv = { ...openshellEnv, - E2E_CLIENT_ID: clientId, - E2E_REFRESH_TOKEN: refreshToken, - E2E_CLIENT_SECRET: clientSecret, + [scenario.clientIdEnvironmentName]: clientId, + [scenario.refreshTokenEnvironmentName]: refreshToken, + [scenario.clientSecretEnvironmentName]: clientSecret, }; await artifacts.target.declare({ - id: "runtime-identity-reference-real-oauth-lifecycle", + id: scenario.targetId, issue: 6871, contract: [ "plan exposes only the provider-neutral, non-secret identity binding", "the blueprint runner imports the profile, creates and attaches the provider through real OpenShell", "apply preserves the already-active provider and model route before attaching identity", "OpenShell exchanges the refresh token at a public HTTPS OAuth endpoint", + `credential delivery is restricted to GET ${scenario.reviewedResourcePath}`, "a sandbox request carries only an opaque placeholder and the protected resource receives the minted bearer", "a second refresh uses the rotated refresh token, and a later child launch receives a new placeholder whose request carries the new bearer", "status, persisted state, command artifacts, and request ledgers contain no OAuth secret material", @@ -535,7 +621,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["plan"]);`, ], { - artifactName: "tc-inf-12-runtime-identity-plan", + artifactName: `${artifactPrefix}-runtime-identity-plan`, artifacts, cwd: workdir, env: runnerEnv, @@ -550,9 +636,9 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug expect(planText).toContain(`"provider_name": "${providerName}"`); expect(planText).toContain(`"credential_key": "${credentialKey}"`); for (const forbidden of [ - "E2E_CLIENT_ID", - "E2E_REFRESH_TOKEN", - "E2E_CLIENT_SECRET", + scenario.clientIdEnvironmentName, + scenario.refreshTokenEnvironmentName, + scenario.clientSecretEnvironmentName, ...redactionValues, ]) { expect(planText).not.toContain(forbidden); @@ -568,7 +654,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["apply"], { runtimeIdentityProfilePolicy: ${JSON.stringify(runtimeIdentityProfilePolicy)} });`, ], { - artifactName: "tc-inf-12-runtime-identity-apply", + artifactName: `${artifactPrefix}-runtime-identity-apply`, artifacts, cwd: workdir, env: runnerEnv, @@ -588,7 +674,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug expect(oauth.tokenRequests()).toEqual([ { method: "POST", - path: "/oauth/token", + path: scenario.tokenPath, grantTypeOk: true, clientIdOk: true, refreshTokenOk: true, @@ -617,9 +703,9 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug inference_provider_created_by_apply: false, }); for (const forbidden of [ - "E2E_CLIENT_ID", - "E2E_REFRESH_TOKEN", - "E2E_CLIENT_SECRET", + scenario.clientIdEnvironmentName, + scenario.refreshTokenEnvironmentName, + scenario.clientSecretEnvironmentName, ...redactionValues, ]) { expect(persistedPlan).not.toContain(forbidden); @@ -628,7 +714,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug const refreshStatus = await sandbox.openshell( ["provider", "refresh", "status", providerName, "--credential-key", credentialKey], { - artifactName: "tc-inf-12-provider-refresh-status-v1", + artifactName: `${artifactPrefix}-provider-refresh-status-v1`, env: openshellEnv, timeoutMs: 30_000, }, @@ -643,7 +729,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug sandboxName, model, [inferenceKey], - "tc-inf-12-inference-after-identity-attach", + `${artifactPrefix}-inference-after-identity-attach`, ); expect(inference.requests().slice(inferenceRequestOffset)).toContainEqual( expect.objectContaining({ @@ -663,7 +749,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug async () => { placeholderProbeAttempt += 1; const probe = await sandbox.exec(sandboxName, ["/usr/bin/printenv", credentialKey], { - artifactName: `tc-inf-12-placeholder-before-rotation-${placeholderProbeAttempt}`, + artifactName: `${artifactPrefix}-placeholder-before-rotation-${placeholderProbeAttempt}`, env: openshellEnv, timeoutMs: 30_000, }); @@ -693,7 +779,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug "-fsS", "-H", `Authorization: Bearer ${projectedPlaceholder}`, - `${tunnel.origin}/resource`, + `${tunnel.origin}${scenario.resourcePath}`, ], { artifactName: `${artifactPrefix}-${attempt}`, @@ -720,21 +806,43 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug }); }; - await expectProtectedResourceVersion(placeholder, 1, "tc-inf-12-protected-resource-v1"); + await expectProtectedResourceVersion(placeholder, 1, `${artifactPrefix}-protected-resource-v1`); expect(oauth.resourceRequests()).toEqual([ { method: "GET", - path: "/resource", + path: scenario.resourcePath, auth: "ok", accessTokenVersion: 1, }, ]); + progress.phase("reject unreviewed credential delivery before bearer substitution"); + const admittedRequestCount = oauth.resourceRequests().length; + const deniedResource = await sandbox.exec( + sandboxName, + [ + "/usr/bin/curl", + "-fsS", + "-X", + scenario.deniedMethod, + "-H", + `Authorization: Bearer ${placeholder}`, + `${tunnel.origin}${scenario.deniedPath}`, + ], + { + artifactName: `${artifactPrefix}-unreviewed-resource-policy`, + env: openshellEnv, + timeoutMs: 60_000, + }, + ); + expect(deniedResource.exitCode, resultText(deniedResource)).not.toBe(0); + expect(oauth.resourceRequests()).toHaveLength(admittedRequestCount); + progress.phase("rotate the credential and relaunch with its new placeholder"); const rotate = await sandbox.openshell( ["provider", "refresh", "rotate", providerName, "--credential-key", credentialKey], { - artifactName: "tc-inf-12-provider-refresh-rotate-v2", + artifactName: `${artifactPrefix}-provider-refresh-rotate-v2`, env: openshellEnv, timeoutMs: 60_000, }, @@ -743,7 +851,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug expect(oauth.tokenRequests()).toHaveLength(2); expect(oauth.tokenRequests()[1]).toEqual({ method: "POST", - path: "/oauth/token", + path: scenario.tokenPath, grantTypeOk: true, clientIdOk: true, refreshTokenOk: true, @@ -761,7 +869,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug sandboxName, ["/usr/bin/printenv", credentialKey], { - artifactName: `tc-inf-12-placeholder-after-rotation-${rotationProbeAttempt}`, + artifactName: `${artifactPrefix}-placeholder-after-rotation-${rotationProbeAttempt}`, env: openshellEnv, timeoutMs: 30_000, }, @@ -779,18 +887,18 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug await expectProtectedResourceVersion( placeholderAfterRotation, 2, - "tc-inf-12-protected-resource-v2", + `${artifactPrefix}-protected-resource-v2`, ); expect(oauth.resourceRequests()).toEqual([ { method: "GET", - path: "/resource", + path: scenario.resourcePath, auth: "ok", accessTokenVersion: 1, }, { method: "GET", - path: "/resource", + path: scenario.resourcePath, auth: "ok", accessTokenVersion: 2, }, @@ -806,7 +914,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["status", "--run-id", ${JSON.stringify(runId)}]);`, ], { - artifactName: "tc-inf-12-runtime-identity-status", + artifactName: `${artifactPrefix}-runtime-identity-status`, artifacts, cwd: workdir, env: runnerEnv, @@ -830,7 +938,7 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["rollback", "--run-id", ${JSON.stringify(runId)}]);`, ], { - artifactName: "tc-inf-12-runtime-identity-rollback", + artifactName: `${artifactPrefix}-runtime-identity-rollback`, artifacts, cwd: workdir, env: runnerEnv, @@ -843,25 +951,43 @@ test("TC-INF-12 runtime identity refreshes and injects a delegated bearer throug expect(fs.existsSync(path.join(stateDir, "rolled_back"))).toBe(true); const providerAfterRollback = await sandbox.openshell(["provider", "get", providerName], { - artifactName: "tc-inf-12-provider-after-rollback", + artifactName: `${artifactPrefix}-provider-after-rollback`, env: openshellEnv, timeoutMs: 30_000, }); expect(providerAfterRollback.exitCode).not.toBe(0); const reusedSandboxAfterRollback = await sandbox.openshell(["sandbox", "get", sandboxName], { - artifactName: "tc-inf-12-reused-sandbox-after-rollback", + artifactName: `${artifactPrefix}-reused-sandbox-after-rollback`, env: openshellEnv, timeoutMs: 30_000, }); expect(reusedSandboxAfterRollback.exitCode, resultText(reusedSandboxAfterRollback)).toBe(0); const deleteProfile = await sandbox.openshell(["provider", "profile", "delete", providerType], { - artifactName: "tc-inf-12-delete-conformance-profile", + artifactName: `${artifactPrefix}-delete-conformance-profile`, env: openshellEnv, timeoutMs: 30_000, }); expect(deleteProfile.exitCode, resultText(deleteProfile)).toBe(0); -}); +} + +test.for(RUNTIME_IDENTITY_E2E_SCENARIOS)( + "TC-INF-%s %sruntime identity refreshes and injects a delegated bearer through real OpenShell", + RUNTIME_IDENTITY_E2E_OPTIONS, + async ( + [testNumber, providerLabel, scenario], + { artifacts, cleanup, host, progress, sandbox, skip }, + ) => { + await runRuntimeIdentityE2EScenario(testNumber, providerLabel, scenario, { + artifacts, + cleanup, + host, + progress, + sandbox, + skip, + }); + }, +); test("TC-INF-09 Deep Agents Code uses a local compatible endpoint through inference.local (#5744)", { timeout: 20 * 60_000, diff --git a/test/e2e/live/runtime-identity-oauth-server.ts b/test/e2e/live/runtime-identity-oauth-server.ts index bdce8e2faf2..2d4edbf5cf9 100644 --- a/test/e2e/live/runtime-identity-oauth-server.ts +++ b/test/e2e/live/runtime-identity-oauth-server.ts @@ -81,6 +81,25 @@ function generateEphemeralTlsMaterial(): { }; } +function classifyBearer( + authorization: string | undefined, + accessTokens: readonly string[], + currentAccessToken: string | undefined, +): Pick { + const token = + typeof authorization === "string" && authorization.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined; + const rawAccessTokenVersion = token + ? accessTokens.findIndex((candidate) => candidate === token) + 1 + : null; + return { + auth: token === currentAccessToken ? "ok" : token ? "invalid" : "missing", + accessTokenVersion: + rawAccessTokenVersion && rawAccessTokenVersion > 0 ? rawAccessTokenVersion : null, + }; +} + /** * Standards-shaped OAuth refresh issuer plus protected resource. * @@ -93,6 +112,8 @@ export async function startRuntimeIdentityOAuthServer(options: { clientId: string; clientSecret: string; initialRefreshToken: string; + resourcePath?: string; + tokenPath?: string; }): Promise { const tls = generateEphemeralTlsMaterial(); const tokenRequests: RuntimeIdentityTokenRequest[] = []; @@ -109,6 +130,8 @@ export async function startRuntimeIdentityOAuthServer(options: { let currentRefreshToken = refreshTokens[0]; let currentAccessToken: string | undefined; let issueCount = 0; + const resourcePath = options.resourcePath ?? "/resource"; + const tokenPath = options.tokenPath ?? "/oauth/token"; const server = https.createServer({ cert: tls.cert, key: tls.key }, async (req, res) => { const requestPath = new URL(req.url ?? "/", "https://runtime-identity.local").pathname; @@ -116,12 +139,12 @@ export async function startRuntimeIdentityOAuthServer(options: { // The public-tunnel readiness probe must not count as protected-resource // evidence and intentionally receives the same unauthenticated response a // real resource would return. - if (req.method === "HEAD" && requestPath === "/resource") { + if (req.method === "HEAD" && requestPath === resourcePath) { writeJsonResponse(res, 401, { error: "missing bearer credential" }); return; } - if (req.method === "POST" && requestPath === "/oauth/token") { + if (req.method === "POST" && requestPath === tokenPath) { const body = new URLSearchParams(await readRequestBody(req)); const grantTypeOk = body.get("grant_type") === "refresh_token"; const clientIdOk = body.get("client_id") === options.clientId; @@ -155,23 +178,17 @@ export async function startRuntimeIdentityOAuthServer(options: { return; } - if (req.method === "GET" && requestPath === "/resource") { - const authorization = req.headers.authorization; - const token = - typeof authorization === "string" && authorization.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : undefined; - const accessTokenVersion = token - ? accessTokens.findIndex((candidate) => candidate === token) + 1 - : null; - const auth: RuntimeIdentityResourceRequest["auth"] = - token === currentAccessToken ? "ok" : token ? "invalid" : "missing"; + if (req.method === "GET" && requestPath === resourcePath) { + const { accessTokenVersion, auth } = classifyBearer( + req.headers.authorization, + accessTokens, + currentAccessToken, + ); resourceRequests.push({ method: req.method, path: requestPath, auth, - accessTokenVersion: - accessTokenVersion && accessTokenVersion > 0 ? accessTokenVersion : null, + accessTokenVersion, }); if (auth !== "ok") { writeJsonResponse(res, 401, { error: "invalid bearer credential" }); @@ -184,6 +201,17 @@ export async function startRuntimeIdentityOAuthServer(options: { return; } + const { accessTokenVersion, auth } = classifyBearer( + req.headers.authorization, + accessTokens, + currentAccessToken, + ); + resourceRequests.push({ + method: req.method ?? "UNKNOWN", + path: requestPath, + auth, + accessTokenVersion, + }); writeJsonResponse(res, 404, { error: "not found" }); });