@homeflare/alchemy 0.48.0 → 0.49.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/README.md +11 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/fake-access.d.ts +40 -0
- package/dist/cloudflare/fake-access.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +7 -2
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
- package/dist/cloudflare/fake-tunnel.d.ts +48 -0
- package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +15 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +518 -7
- package/dist/cloudflare/index.js.map +15 -4
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
- package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
- package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
- package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
- package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc.d.ts +36 -0
- package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
- package/dist/talos/cluster-adapter.d.ts +2 -1
- package/dist/talos/cluster-adapter.d.ts.map +1 -1
- package/dist/talos/index.js +19 -8
- package/dist/talos/index.js.map +6 -6
- package/dist/talos/mint-temp-file.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +29 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/ownership.md +3 -2
- package/docs/saas-oidc.md +185 -0
- package/docs/talos-openbao-adapter.md +3 -2
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +1 -1
- package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
- package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
- package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
- package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
- package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
- package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
- package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
- package/src/cloudflare/cloudflared-tunnel.ts +116 -0
- package/src/cloudflare/fake-access.ts +196 -0
- package/src/cloudflare/fake-mesh.ts +6 -3
- package/src/cloudflare/fake-tunnel.ts +148 -0
- package/src/cloudflare/index.ts +27 -4
- package/src/cloudflare/providers.ts +16 -3
- package/src/cloudflare/saas-oidc-api.ts +224 -0
- package/src/cloudflare/saas-oidc-consumer.ts +35 -0
- package/src/cloudflare/saas-oidc-error.ts +52 -0
- package/src/cloudflare/saas-oidc-form.test.ts +227 -0
- package/src/cloudflare/saas-oidc-form.ts +190 -0
- package/src/cloudflare/saas-oidc-harness.ts +140 -0
- package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
- package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
- package/src/cloudflare/saas-oidc-props.ts +147 -0
- package/src/cloudflare/saas-oidc-read.test.ts +85 -0
- package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
- package/src/cloudflare/saas-oidc-state.test.ts +128 -0
- package/src/cloudflare/saas-oidc-team.test.ts +88 -0
- package/src/cloudflare/saas-oidc-team.ts +70 -0
- package/src/cloudflare/saas-oidc-wire.ts +116 -0
- package/src/cloudflare/saas-oidc.test.ts +201 -0
- package/src/cloudflare/saas-oidc.ts +107 -0
- package/src/talos/cluster-adapter-errors.ts +2 -1
- package/src/talos/cluster-adapter-move.test.ts +2 -0
- package/src/talos/cluster-adapter.ts +2 -1
- package/src/talos/mint-temp-file.test.ts +44 -0
- package/src/talos/mint-temp-file.ts +16 -4
- package/src/talos/talosctl-probe-kill.test.ts +56 -0
- package/src/talos/talosctl.test.ts +54 -1
- package/src/talos/talosctl.ts +34 -2
package/src/cloudflare/index.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
|
|
3
3
|
*
|
|
4
|
-
* ★ Built on vendor SDKs, not hand-rolled HTTP.
|
|
5
|
-
* `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare
|
|
6
|
-
* over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over
|
|
7
|
-
* `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts).
|
|
4
|
+
* ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock`, `MeshNode`, `CloudflaredTunnel` and
|
|
5
|
+
* `SaasOidcApplication` all call `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare
|
|
6
|
+
* providers use — R2BucketLock over `/r2` (`getBucketLock`/`putBucketLock`), MeshNode over
|
|
7
|
+
* `/zero-trust`, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). None
|
|
8
|
+
* invents a path string.
|
|
9
|
+
* ⚠️ ONE EXCEPTION, A READ: `SaasOidcApplication` writes and looks up names through the SDK, but its
|
|
10
|
+
* responses drop `saas_app`, so `getApp` reads that one object from the wire JSON — the same
|
|
11
|
+
* `GET /accounts/{account_id}/access/apps/{app_id}` the SDK's get verb sends, over the SDK's own
|
|
12
|
+
* `HttpClient`, credentials and base URL (saas-oidc-api.ts).
|
|
8
13
|
*/
|
|
9
14
|
export type { R2LockRule } from './lock-rules.ts';
|
|
10
15
|
export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
|
|
@@ -12,6 +17,24 @@ export { MeshNode, MeshNodeProvider, type MeshNodeServices } from './mesh-node.t
|
|
|
12
17
|
export type { MeshNodeAttributes, MeshNodeProps } from './mesh-node-form.ts';
|
|
13
18
|
export { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
|
|
14
19
|
export { fetchMeshNodeToken, type MeshNodeRef } from './mesh-node-token.ts';
|
|
20
|
+
export {
|
|
21
|
+
CloudflaredTunnel,
|
|
22
|
+
CloudflaredTunnelProvider,
|
|
23
|
+
type CloudflaredTunnelServices,
|
|
24
|
+
} from './cloudflared-tunnel.ts';
|
|
25
|
+
export type {
|
|
26
|
+
CloudflaredTunnelAttributes,
|
|
27
|
+
CloudflaredTunnelProps,
|
|
28
|
+
} from './cloudflared-tunnel-form.ts';
|
|
29
|
+
export { CloudflaredTunnelError } from './cloudflared-tunnel-api.ts';
|
|
30
|
+
export { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
|
|
31
|
+
export type {
|
|
32
|
+
OidcGrantType,
|
|
33
|
+
OidcScope,
|
|
34
|
+
SaasOidcApplicationAttributes,
|
|
35
|
+
SaasOidcApplicationProps,
|
|
36
|
+
} from './saas-oidc-form.ts';
|
|
37
|
+
export { SaasOidcError } from './saas-oidc-error.ts';
|
|
15
38
|
export { providers } from './providers.ts';
|
|
16
39
|
export {
|
|
17
40
|
astroWebsite,
|
|
@@ -13,26 +13,39 @@
|
|
|
13
13
|
* `Cloudflare.providers()` does (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
|
|
14
14
|
* `CLOUDFLARE_ACCOUNT_ID` in CI) — a lock and the Gateway rules that reference the same bucket
|
|
15
15
|
* land in the same account.
|
|
16
|
-
* ⚠️ THE LAYER IS BUILT
|
|
16
|
+
* ⚠️ THE LAYER IS BUILT ONCE PER PROVIDER ON PURPOSE, even when the stack also merges
|
|
17
17
|
* `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: each feeds its own provider
|
|
18
18
|
* only and adds nothing to the stack's context.
|
|
19
19
|
*/
|
|
20
20
|
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
21
21
|
import * as Provider from 'alchemy/Provider';
|
|
22
22
|
import * as Layer from 'effect/Layer';
|
|
23
|
+
import { CloudflaredTunnel, CloudflaredTunnelProvider } from './cloudflared-tunnel.ts';
|
|
23
24
|
import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
|
|
24
25
|
import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
|
|
26
|
+
import { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
|
|
25
27
|
|
|
26
28
|
export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
|
|
27
29
|
|
|
28
|
-
// ⛔ `orDie` ON
|
|
30
|
+
// ⛔ `orDie` ON EACH of the four, as `Cloudflare.providers()` ends with: a stack's providers layer must not
|
|
29
31
|
// fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
|
|
30
32
|
export const providers = () =>
|
|
31
|
-
Layer.effect(
|
|
33
|
+
Layer.effect(
|
|
34
|
+
Providers,
|
|
35
|
+
Provider.collection([R2BucketLock, MeshNode, CloudflaredTunnel, SaasOidcApplication]),
|
|
36
|
+
).pipe(
|
|
32
37
|
Layer.provide(
|
|
33
38
|
Layer.mergeAll(
|
|
34
39
|
R2BucketLockProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
35
40
|
MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
41
|
+
CloudflaredTunnelProvider().pipe(
|
|
42
|
+
Layer.provide(Cloudflare.CloudflareApiLive()),
|
|
43
|
+
Layer.orDie,
|
|
44
|
+
),
|
|
45
|
+
SaasOidcApplicationProvider().pipe(
|
|
46
|
+
Layer.provide(Cloudflare.CloudflareApiLive()),
|
|
47
|
+
Layer.orDie,
|
|
48
|
+
),
|
|
36
49
|
),
|
|
37
50
|
),
|
|
38
51
|
);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Access application calls a SaaS OIDC application needs, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* ★ OVER `@distilled.cloud/cloudflare` FOR EVERY WRITE AND FOR THE NAME LOOKUP; ONE RAW READ. Alchemy
|
|
5
|
+
* `2.0.0-beta.81`'s `Cloudflare.Access.Application` has no `saas_app` at all (Application.ts:
|
|
6
|
+
* `saas` appears only as an app type and in two doc comments), so this resource exists. The SDK
|
|
7
|
+
* does carry `saasApp` on the REQUEST (`AccessApplicationsCreateRequestSaasAppOIDCSaaSApp`,
|
|
8
|
+
* zero_trust.ts, `@distilled.cloud/cloudflare@1.0.0-rc.13`), so create and update go through it.
|
|
9
|
+
* ⚠️ BUT ITS RESPONSES DROP IT. Measured 2026-10-09 against rc.13 with a fake API that answered a
|
|
10
|
+
* full `saas_app` object: `getAccessApplicationForAccount` and `createAccessApplicationForAccount`
|
|
11
|
+
* both decoded to `{ domain, type, id, aud, name, policies }`, with no `saasApp`. The response
|
|
12
|
+
* union is discriminated by field names (`T.UnionCases`) and a saas app falls in a variant with
|
|
13
|
+
* no `saas_app`. So the OIDC client id, redirect URIs, scopes and grant types can only be read
|
|
14
|
+
* from the wire JSON, and `getApp` below does that through the `HttpClient` the SDK itself runs
|
|
15
|
+
* on, with the SDK's own credentials and base URL. It invents no path: it is the same
|
|
16
|
+
* `GET /accounts/{account_id}/access/apps/{app_id}` the SDK's get verb sends.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ THE CLIENT SECRET NEVER REACHES THIS PACKAGE'S MEMORY. The field doc on the SDK schema says
|
|
19
|
+
* "The application client secret, only returned on POST request". The create response is
|
|
20
|
+
* decoded by the SDK, whose `saasApp`-less variant drops it, and `getApp` copies named fields
|
|
21
|
+
* only and never reads `client_secret`. TWO paths remain where the SDK itself holds or prints the
|
|
22
|
+
* raw body (`@distilled.cloud/cloudflare@1.0.0-rc.13`, protocol.ts):
|
|
23
|
+
* 1. `DISTILLED_DEBUG_HTTP` makes it `console.error` the first 400 characters of every response
|
|
24
|
+
* (line 334), so `refuseDebugHttp` stops a write while it is set. THIS ONE STAYS AN
|
|
25
|
+
* OPERATOR-ONLY SWITCH: the package can refuse to run, it cannot stop the SDK printing.
|
|
26
|
+
* 2. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
|
|
27
|
+
* (lines 452-457), whose `body` is the whole parsed response, create response included.
|
|
28
|
+
* ⛔ SANITISED HERE: every SDK call of this resource (create, update, list, delete, and the
|
|
29
|
+
* organization read in saas-oidc-team.ts) catches that tag at the call and fails with a
|
|
30
|
+
* `SaasOidcError` that names the operation and nothing from the response. Why, when it can
|
|
31
|
+
* happen at all, and what rc.13 will and will not raise: saas-oidc-error.ts (`withheld`).
|
|
32
|
+
* The raw `get` below is not an SDK call and never builds that error.
|
|
33
|
+
* A PUBLIC PKCE client (`allowPkceWithoutClientSecret: true`) has no secret at all, so neither
|
|
34
|
+
* path exposes one for the Headlamp app.
|
|
35
|
+
*/
|
|
36
|
+
import { Credentials, formatHeaders } from '@distilled.cloud/cloudflare/Credentials';
|
|
37
|
+
import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
|
|
38
|
+
import * as Effect from 'effect/Effect';
|
|
39
|
+
import * as Schedule from 'effect/Schedule';
|
|
40
|
+
import * as Stream from 'effect/Stream';
|
|
41
|
+
import * as HttpClient from 'effect/http/HttpClient';
|
|
42
|
+
import * as HttpClientRequest from 'effect/http/HttpClientRequest';
|
|
43
|
+
import { SaasOidcError, withheld } from './saas-oidc-error.ts';
|
|
44
|
+
import { type AppWrite, parseApp, record } from './saas-oidc-wire.ts';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The two SDK writes, as properties of an object so saas-oidc-parse-error.test.ts can stand in for
|
|
48
|
+
* one: rc.13 cannot be made to fail a create or update with `CloudflareParseError` (the ⚠️ in
|
|
49
|
+
* saas-oidc-error.ts), and the catch on those two calls still has to be proven wired. Nothing
|
|
50
|
+
* else assigns here.
|
|
51
|
+
*/
|
|
52
|
+
export const sdkWrites = {
|
|
53
|
+
create: zeroTrust.createAccessApplicationForAccount,
|
|
54
|
+
update: zeroTrust.updateAccessApplicationForAccount,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const TRANSIENT_STATUS = (status: number): boolean =>
|
|
58
|
+
status === 403 || status === 429 || status >= 500;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ★ UPSTREAM'S OWN RETRY, EXACTLY (alchemy beta.81 `Cloudflare/Access/Application.ts`, the comment
|
|
62
|
+
* and `retryTransientAccessError` above `ApplicationProvider`): `AccessReferenceNotFound` is a
|
|
63
|
+
* freshly created policy still propagating, and `Forbidden` is how Cloudflare throttles a valid
|
|
64
|
+
* token. Capped exponential, about 45 seconds, then fail. The raw read joins it for the same
|
|
65
|
+
* statuses so it is throttled the way the SDK calls are.
|
|
66
|
+
*/
|
|
67
|
+
export const retryTransient = <A, E extends { readonly _tag: string }, R>(
|
|
68
|
+
effect: Effect.Effect<A, E, R>,
|
|
69
|
+
) =>
|
|
70
|
+
effect.pipe(
|
|
71
|
+
Effect.retry({
|
|
72
|
+
while: (e) =>
|
|
73
|
+
e._tag === 'AccessReferenceNotFound' ||
|
|
74
|
+
e._tag === 'Forbidden' ||
|
|
75
|
+
(e._tag === 'SaasOidcError' &&
|
|
76
|
+
TRANSIENT_STATUS((e as unknown as SaasOidcError).status ?? 0)),
|
|
77
|
+
schedule: Schedule.max([
|
|
78
|
+
Schedule.min([Schedule.exponential('1 second', 1.5), Schedule.spaced('5 seconds')]),
|
|
79
|
+
Schedule.recurs(12),
|
|
80
|
+
]),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ⛔ REFUSED BEFORE ANY WRITE while `DISTILLED_DEBUG_HTTP` is set: the SDK would print the create
|
|
86
|
+
* response, which carries the client secret, to stderr (protocol.ts). Read from `process.env`
|
|
87
|
+
* because that is what distilled reads, as mesh-node-token.ts does.
|
|
88
|
+
*/
|
|
89
|
+
export const refuseDebugHttp = Effect.suspend(() =>
|
|
90
|
+
(globalThis.process?.env?.['DISTILLED_DEBUG_HTTP'] ?? '') === ''
|
|
91
|
+
? Effect.void
|
|
92
|
+
: Effect.fail(
|
|
93
|
+
new SaasOidcError({
|
|
94
|
+
message:
|
|
95
|
+
'Refusing to write a SaaS OIDC application while DISTILLED_DEBUG_HTTP is set: the SDK would print the response, which carries the client secret, to stderr. Unset it and run again.',
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The app with this id, from the wire JSON, or `undefined` when it is gone (404).
|
|
102
|
+
* ⚠️ Error messages carry the reason and the status only: the request holds the bearer token.
|
|
103
|
+
*/
|
|
104
|
+
export const getApp = (accountId: string, appId: string) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const credentials = yield* yield* Credentials;
|
|
107
|
+
const client = yield* HttpClient.HttpClient;
|
|
108
|
+
const request = HttpClientRequest.get(
|
|
109
|
+
`${credentials.apiBaseUrl}/accounts/${accountId}/access/apps/${appId}`,
|
|
110
|
+
).pipe(HttpClientRequest.setHeaders(formatHeaders(credentials)), HttpClientRequest.acceptJson);
|
|
111
|
+
const transport = (cause: { readonly message: string }) =>
|
|
112
|
+
new SaasOidcError({ message: `Access application GET failed: ${cause.message}`, status: 0 });
|
|
113
|
+
const response = yield* client.execute(request).pipe(Effect.mapError(transport));
|
|
114
|
+
if (response.status === 404) return undefined;
|
|
115
|
+
if (response.status < 200 || response.status >= 300) {
|
|
116
|
+
return yield* Effect.fail(
|
|
117
|
+
new SaasOidcError({
|
|
118
|
+
message: `Access application GET returned ${response.status}`,
|
|
119
|
+
status: response.status,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const text = yield* response.text.pipe(Effect.mapError(transport));
|
|
124
|
+
const body = yield* Effect.try({
|
|
125
|
+
try: () => record(JSON.parse(text) as unknown),
|
|
126
|
+
catch: () =>
|
|
127
|
+
new SaasOidcError({ message: 'Access application GET was not JSON', status: 200 }),
|
|
128
|
+
});
|
|
129
|
+
const result = record(body?.['result']);
|
|
130
|
+
return result === undefined ? undefined : parseApp(result);
|
|
131
|
+
}).pipe(retryTransient);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The live saas app with exactly this name, or `undefined`. The list payload carries no `saas_app`
|
|
135
|
+
* (see the ⚠️ above), so the caller re-reads the match by id with `getApp`.
|
|
136
|
+
* ⛔ Two matches are refused rather than resolved: Access does not enforce unique app names, so two
|
|
137
|
+
* means this package cannot tell which one is meant.
|
|
138
|
+
*/
|
|
139
|
+
export const findSaasByName = (accountId: string, name: string) =>
|
|
140
|
+
zeroTrust.listAccessApplicationsForAccount.items({ accountId }).pipe(
|
|
141
|
+
Stream.filter((app) => app.type === 'saas' && app.name === name),
|
|
142
|
+
Stream.runCollect,
|
|
143
|
+
Effect.catchTag('CloudflareParseError', withheld('application list')),
|
|
144
|
+
retryTransient,
|
|
145
|
+
Effect.flatMap((chunk) => {
|
|
146
|
+
const ids = Array.from(chunk).flatMap((app) => (app.id == null ? [] : [app.id]));
|
|
147
|
+
if (ids.length <= 1) return Effect.succeed(ids[0]);
|
|
148
|
+
return Effect.fail(
|
|
149
|
+
new SaasOidcError({
|
|
150
|
+
message: `SaaS application name "${name}" matches ${ids.length} live apps (${ids.join(', ')}). Refusing to guess which one is meant; set \`applicationId\`.`,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const sdkBody = (write: AppWrite) => ({
|
|
157
|
+
type: 'saas' as const,
|
|
158
|
+
name: write.name,
|
|
159
|
+
sessionDuration: write.sessionDuration,
|
|
160
|
+
...(write.allowedIdps === undefined ? {} : { allowedIdps: [...write.allowedIdps] }),
|
|
161
|
+
...(write.autoRedirectToIdentity === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { autoRedirectToIdentity: write.autoRedirectToIdentity }),
|
|
164
|
+
appLauncherVisible: write.appLauncherVisible,
|
|
165
|
+
policies: [...write.policies],
|
|
166
|
+
saasApp: {
|
|
167
|
+
authType: write.saasApp.authType,
|
|
168
|
+
redirectUris: [...write.saasApp.redirectUris],
|
|
169
|
+
scopes: [...write.saasApp.scopes],
|
|
170
|
+
grantTypes: [...write.saasApp.grantTypes],
|
|
171
|
+
accessTokenLifetime: write.saasApp.accessTokenLifetime,
|
|
172
|
+
allowPkceWithoutClientSecret: write.saasApp.allowPkceWithoutClientSecret,
|
|
173
|
+
...(write.saasApp.refreshTokenOptions === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: { refreshTokenOptions: write.saasApp.refreshTokenOptions }),
|
|
176
|
+
...(write.saasApp.appLauncherUrl === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { appLauncherUrl: write.saasApp.appLauncherUrl }),
|
|
179
|
+
} satisfies zeroTrust.AccessApplicationsCreateRequestSaasAppOIDCSaaSApp,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/** Create the app and return its id. The response's other fields are not trusted: re-read it. */
|
|
183
|
+
export const createApp = (accountId: string, write: AppWrite) =>
|
|
184
|
+
refuseDebugHttp.pipe(
|
|
185
|
+
Effect.andThen(
|
|
186
|
+
sdkWrites
|
|
187
|
+
.create({ accountId, ...sdkBody(write) })
|
|
188
|
+
.pipe(
|
|
189
|
+
Effect.catchTag('CloudflareParseError', withheld('application create')),
|
|
190
|
+
retryTransient,
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
Effect.flatMap((created) =>
|
|
194
|
+
created.id == null
|
|
195
|
+
? Effect.fail(new SaasOidcError({ message: 'SaaS OIDC create did not return an id.' }))
|
|
196
|
+
: Effect.succeed(created.id),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export const updateApp = (accountId: string, appId: string, write: AppWrite) =>
|
|
201
|
+
refuseDebugHttp.pipe(
|
|
202
|
+
Effect.andThen(
|
|
203
|
+
sdkWrites
|
|
204
|
+
.update({ accountId, appId, ...sdkBody(write) })
|
|
205
|
+
.pipe(
|
|
206
|
+
Effect.catchTag('CloudflareParseError', withheld('application update')),
|
|
207
|
+
retryTransient,
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
Effect.asVoid,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Idempotent: an app already gone is a successful delete. Any other failure propagates, except
|
|
215
|
+
* that a response the SDK cannot validate is withheld (see `withheld`).
|
|
216
|
+
*/
|
|
217
|
+
export const deleteApp = (accountId: string, appId: string) =>
|
|
218
|
+
zeroTrust.deleteAccessApplicationForAccount({ accountId, appId }).pipe(
|
|
219
|
+
Effect.asVoid,
|
|
220
|
+
Effect.catchTags({
|
|
221
|
+
AccessApplicationNotFound: () => Effect.void,
|
|
222
|
+
CloudflareParseError: withheld('application delete'),
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test-only downstream resource for saas-oidc-stable.test.ts: it takes the app's issuer as a prop
|
|
3
|
+
* and counts how often the engine reconciles it, so a test can tell "planned noop" from "planned
|
|
4
|
+
* update" for a consumer of `app.issuer`. ⛔ TEST-ONLY. No provider imports this file.
|
|
5
|
+
*/
|
|
6
|
+
import * as Provider from 'alchemy/Provider';
|
|
7
|
+
import { Resource } from 'alchemy/Resource';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
|
|
10
|
+
export interface IssuerConsumer extends Resource<
|
|
11
|
+
'Test.IssuerConsumer',
|
|
12
|
+
{ readonly issuer: string; readonly jwks: string },
|
|
13
|
+
{ readonly issuer: string; readonly jwks: string }
|
|
14
|
+
> {}
|
|
15
|
+
export const IssuerConsumer = Resource<IssuerConsumer>('Test.IssuerConsumer');
|
|
16
|
+
|
|
17
|
+
/** The provider, and the list of props it was reconciled with (one entry per reconcile). */
|
|
18
|
+
export const consumerProvider = () => {
|
|
19
|
+
const reconciled: string[] = [];
|
|
20
|
+
const layer = Provider.effect(
|
|
21
|
+
IssuerConsumer,
|
|
22
|
+
Effect.succeed(
|
|
23
|
+
IssuerConsumer.Provider.of({
|
|
24
|
+
list: () => Effect.succeed([]),
|
|
25
|
+
reconcile: ({ news }) =>
|
|
26
|
+
Effect.sync(() => {
|
|
27
|
+
reconciled.push(news.issuer);
|
|
28
|
+
return { issuer: news.issuer, jwks: news.jwks };
|
|
29
|
+
}),
|
|
30
|
+
delete: () => Effect.void,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
return { layer, reconciled };
|
|
35
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error type of the SaaS OIDC resource, and the handler that keeps the SDK's raw
|
|
3
|
+
* `CloudflareParseError` (which can hold the client secret) from leaving this package.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE LEAK. A response that fails schema validation becomes `CloudflareParseError({ body, cause })`
|
|
6
|
+
* (`@distilled.cloud/cloudflare@1.0.0-rc.13` protocol.ts:452-457). `body` is the WHOLE parsed
|
|
7
|
+
* response, a create response included, and for a confidential client that carries
|
|
8
|
+
* `client_secret`; `cause` is a SchemaError that quotes the values that failed. A raw SDK error
|
|
9
|
+
* that reached an engine log or a trace would print both.
|
|
10
|
+
* ★ THE FIX is at every SDK call of this resource (create, update, list, delete, and the
|
|
11
|
+
* organization read in saas-oidc-team.ts): `Effect.catchTag('CloudflareParseError', withheld(…))`,
|
|
12
|
+
* applied before the retry or any other combinator sees the error. It is the house's catchTag
|
|
13
|
+
* doctrine (a typed tag, no status or message sniffing), and the replacement carries the
|
|
14
|
+
* operation name and nothing from the response. The raw `get` in saas-oidc-api.ts is not an SDK
|
|
15
|
+
* call and never builds this error.
|
|
16
|
+
* ⚠️ WHEN IT CAN HAPPEN AT ALL. The error exists only under STRICT response validation (core
|
|
17
|
+
* response-validation.ts:29 "without one, every call is lenient", :104-116); neither Alchemy nor
|
|
18
|
+
* this kit turns strict on, so a consumer's own `ResponseValidation.strict` is what makes it
|
|
19
|
+
* reachable. MEASURED 2026-10-09 against rc.13 under strict validation: a create or update
|
|
20
|
+
* response decodes against `S.Unknown` (`AccessApplicationsCreateResult`, zero_trust.ts:35025;
|
|
21
|
+
* `AccessApplicationsUpdateResult`, :210218), which accepts anything, so the real SDK cannot
|
|
22
|
+
* raise it for those two today (the tests reach them through `sdkWrites` in saas-oidc-api.ts).
|
|
23
|
+
* List (a non-array result, :147403-147410), delete (`id` typed, :61601-61607) and the organization
|
|
24
|
+
* read (:169193-169253) are typed and can. The catch is on all five so a later SDK that types the
|
|
25
|
+
* union cannot start leaking.
|
|
26
|
+
* ⛔ `DISTILLED_DEBUG_HTTP` is the other raw-body path and stays an operator-only switch: the
|
|
27
|
+
* package refuses a write while it is set (`refuseDebugHttp`), it cannot stop the SDK printing.
|
|
28
|
+
*/
|
|
29
|
+
import * as Data from 'effect/Data';
|
|
30
|
+
import * as Effect from 'effect/Effect';
|
|
31
|
+
|
|
32
|
+
/** A refusal or failure, carrying the sentence an operator needs. Never a secret. */
|
|
33
|
+
export class SaasOidcError extends Data.TaggedError('SaasOidcError')<{
|
|
34
|
+
readonly message: string;
|
|
35
|
+
/** The HTTP status when the failure is a response (0 for a transport failure). */
|
|
36
|
+
readonly status?: number;
|
|
37
|
+
}> {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ⛔ THE ONLY THING THIS PACKAGE SAYS ABOUT A RESPONSE THE SDK COULD NOT VALIDATE. The handler for
|
|
41
|
+
* `Effect.catchTag('CloudflareParseError', …)`: it never touches the error it replaces, because
|
|
42
|
+
* that error's `body` is the whole response (a create's carries `client_secret`) and its `cause`
|
|
43
|
+
* quotes the values that failed. The message names the operation only. No status is claimed: the
|
|
44
|
+
* SDK builds this error after its failure branch (protocol.ts:359, :452), so the response was a
|
|
45
|
+
* success as far as HTTP and the envelope go, and nothing more is known.
|
|
46
|
+
*/
|
|
47
|
+
export const withheld = (operation: string) => (): Effect.Effect<never, SaasOidcError> =>
|
|
48
|
+
Effect.fail(
|
|
49
|
+
new SaasOidcError({
|
|
50
|
+
message: `Access ${operation} succeeded at the HTTP level but the SDK could not validate the response. The response is withheld on purpose (a create response carries the client secret). Check the application in the Zero Trust dashboard, then run again.`,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of SaasOidcApplication: validation, the issuer URLs (the team domain is a prop,
|
|
3
|
+
* never a constant), drift, the write body and the attribute mapping.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { parseApp } from './saas-oidc-wire.ts';
|
|
7
|
+
import {
|
|
8
|
+
checkTeam,
|
|
9
|
+
issuerFor,
|
|
10
|
+
needsSync,
|
|
11
|
+
toAttributes,
|
|
12
|
+
validateSaasOidc,
|
|
13
|
+
writeBody,
|
|
14
|
+
} from './saas-oidc-form.ts';
|
|
15
|
+
import { publicClient } from './saas-oidc-harness.ts';
|
|
16
|
+
|
|
17
|
+
const live = (over: Record<string, unknown> = {}) =>
|
|
18
|
+
parseApp({
|
|
19
|
+
id: 'app-1',
|
|
20
|
+
aud: 'aud-1',
|
|
21
|
+
type: 'saas',
|
|
22
|
+
name: 'Headlamp',
|
|
23
|
+
domain: 'example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/cid-1',
|
|
24
|
+
policies: [{ id: 'policy-admin' }],
|
|
25
|
+
saas_app: {
|
|
26
|
+
auth_type: 'oidc',
|
|
27
|
+
client_id: 'cid-1',
|
|
28
|
+
client_secret: 'must-never-be-read',
|
|
29
|
+
redirect_uris: ['https://headlamp.example.test/oidc-callback'],
|
|
30
|
+
scopes: ['openid', 'email', 'groups'],
|
|
31
|
+
grant_types: ['authorization_code_with_pkce'],
|
|
32
|
+
access_token_lifetime: '15m',
|
|
33
|
+
allow_pkce_without_client_secret: true,
|
|
34
|
+
},
|
|
35
|
+
...over,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('validateSaasOidc', () => {
|
|
39
|
+
test('accepts a public PKCE client', () => {
|
|
40
|
+
expect(validateSaasOidc(publicClient())).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test.each([
|
|
44
|
+
'',
|
|
45
|
+
'https://example.cloudflareaccess.com',
|
|
46
|
+
'example.cloudflareaccess.com/x',
|
|
47
|
+
'Team',
|
|
48
|
+
'nodot',
|
|
49
|
+
])('refuses teamDomain %p', (teamDomain) => {
|
|
50
|
+
expect(validateSaasOidc(publicClient({ teamDomain }))?.message).toContain('teamDomain');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('refuses the public-client flag without the PKCE grant', () => {
|
|
54
|
+
const props = publicClient();
|
|
55
|
+
const bad = {
|
|
56
|
+
...props,
|
|
57
|
+
saasApp: { ...props.saasApp, grantTypes: ['authorization_code' as const] },
|
|
58
|
+
};
|
|
59
|
+
expect(validateSaasOidc(bad)?.message).toContain('authorization_code_with_pkce');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('refuses an empty name', () => {
|
|
63
|
+
expect(validateSaasOidc(publicClient({ name: ' ' }))?.message).toContain('name');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('issuer', () => {
|
|
68
|
+
test('is built from the declared team domain, whatever it is', () => {
|
|
69
|
+
expect(issuerFor('other.cloudflareaccess.com', 'c')).toBe(
|
|
70
|
+
'https://other.cloudflareaccess.com/cdn-cgi/access/sso/oidc/c',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the attributes expose issuer, clientId and jwksEndpoint for the declared team', () => {
|
|
75
|
+
const attrs = toAttributes(live(), 'acct', 'x', 'example.cloudflareaccess.com');
|
|
76
|
+
expect(attrs).toMatchObject({
|
|
77
|
+
teamDomain: 'example.cloudflareaccess.com',
|
|
78
|
+
clientId: 'cid-1',
|
|
79
|
+
issuer: 'https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/cid-1',
|
|
80
|
+
jwksEndpoint: 'https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/cid-1/jwks',
|
|
81
|
+
configurationEndpoint:
|
|
82
|
+
'https://example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/cid-1/.well-known/openid-configuration',
|
|
83
|
+
});
|
|
84
|
+
const other = toAttributes(live(), 'acct', 'x', 'other.cloudflareaccess.com');
|
|
85
|
+
expect(other?.issuer).toStartWith('https://other.cloudflareaccess.com/');
|
|
86
|
+
expect(JSON.stringify(attrs)).not.toContain('schenanigans');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('with no declared team (state from before the prop) the live domain host stands in', () => {
|
|
90
|
+
expect(toAttributes(live(), 'acct', 'x', undefined)?.teamDomain).toBe(
|
|
91
|
+
'example.cloudflareaccess.com',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the client secret the wire carries reaches neither the parse nor the attributes', () => {
|
|
96
|
+
expect(JSON.stringify(live())).not.toContain('must-never-be-read');
|
|
97
|
+
expect(
|
|
98
|
+
JSON.stringify(toAttributes(live(), 'acct', 'x', 'example.cloudflareaccess.com')),
|
|
99
|
+
).not.toContain('must-never-be-read');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('an app with no client id is not describable', () => {
|
|
103
|
+
expect(toAttributes(live({ saas_app: { auth_type: 'oidc' } }), 'a', 'x', 'e.test.com')).toBe(
|
|
104
|
+
undefined,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a declared team that disagrees with the app is refused', () => {
|
|
109
|
+
expect(checkTeam('other.cloudflareaccess.com', live())?.message).toContain(
|
|
110
|
+
'example.cloudflareaccess.com',
|
|
111
|
+
);
|
|
112
|
+
expect(checkTeam('EXAMPLE.cloudflareaccess.com', live())).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('with no domain reported, a declaration that moves the recorded team is refused', () => {
|
|
116
|
+
const noDomain = live({ domain: undefined });
|
|
117
|
+
expect(
|
|
118
|
+
checkTeam('other.cloudflareaccess.com', noDomain, 'example.cloudflareaccess.com')?.message,
|
|
119
|
+
).toContain('issuer cannot move');
|
|
120
|
+
expect(
|
|
121
|
+
checkTeam('EXAMPLE.cloudflareaccess.com', noDomain, 'example.cloudflareaccess.com'),
|
|
122
|
+
).toBeUndefined();
|
|
123
|
+
// Nothing recorded (first create, cold adoption): nothing to compare, the declaration stands.
|
|
124
|
+
expect(checkTeam('other.cloudflareaccess.com', noDomain)).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('needsSync', () => {
|
|
129
|
+
test('is false for a live app that matches the declaration', () => {
|
|
130
|
+
expect(needsSync(publicClient(), live(), 'Headlamp')).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('flips on redirect URIs, grants, PKCE flag, lifetime, policies and name', () => {
|
|
134
|
+
const props = publicClient();
|
|
135
|
+
const withSaas = (patch: object) => ({ ...props, saasApp: { ...props.saasApp, ...patch } });
|
|
136
|
+
expect(needsSync(withSaas({ redirectUris: ['https://x.test/cb'] }), live(), 'Headlamp')).toBe(
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
expect(needsSync(withSaas({ grantTypes: ['authorization_code'] }), live(), 'Headlamp')).toBe(
|
|
140
|
+
true,
|
|
141
|
+
);
|
|
142
|
+
expect(needsSync(withSaas({ allowPkceWithoutClientSecret: false }), live(), 'Headlamp')).toBe(
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
expect(needsSync(withSaas({ accessTokenLifetime: '1h' }), live(), 'Headlamp')).toBe(true);
|
|
146
|
+
expect(needsSync(withSaas({ refreshTokenLifetime: '30d' }), live(), 'Headlamp')).toBe(true);
|
|
147
|
+
expect(needsSync({ ...props, policies: ['a', 'b'] }, live(), 'Headlamp')).toBe(true);
|
|
148
|
+
expect(needsSync(props, live(), 'Renamed')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('ignores the order of sets', () => {
|
|
152
|
+
const props = publicClient();
|
|
153
|
+
const reordered = {
|
|
154
|
+
...props,
|
|
155
|
+
saasApp: { ...props.saasApp, scopes: ['groups', 'openid', 'email'] as const },
|
|
156
|
+
};
|
|
157
|
+
expect(needsSync(reordered, live(), 'Headlamp')).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('policy order is part of the declaration', () => {
|
|
162
|
+
const ranked = (policies: Array<{ id: string; precedence?: number }>) =>
|
|
163
|
+
live({ policies: policies });
|
|
164
|
+
|
|
165
|
+
test('the same policies in a different order are drift', () => {
|
|
166
|
+
const props = publicClient({ policies: ['policy-a', 'policy-b'] });
|
|
167
|
+
const inOrder = ranked([
|
|
168
|
+
{ id: 'policy-a', precedence: 1 },
|
|
169
|
+
{ id: 'policy-b', precedence: 2 },
|
|
170
|
+
]);
|
|
171
|
+
const swapped = ranked([
|
|
172
|
+
{ id: 'policy-b', precedence: 1 },
|
|
173
|
+
{ id: 'policy-a', precedence: 2 },
|
|
174
|
+
]);
|
|
175
|
+
expect(needsSync(props, inOrder, 'Headlamp')).toBe(false);
|
|
176
|
+
expect(needsSync(props, swapped, 'Headlamp')).toBe(true);
|
|
177
|
+
expect(needsSync({ ...props, policies: ['policy-b', 'policy-a'] }, inOrder, 'Headlamp')).toBe(
|
|
178
|
+
true,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('the live order is the API precedence, whatever order the array arrives in', () => {
|
|
183
|
+
const arrival = ranked([
|
|
184
|
+
{ id: 'policy-b', precedence: 2 },
|
|
185
|
+
{ id: 'policy-a', precedence: 1 },
|
|
186
|
+
]);
|
|
187
|
+
expect(arrival.policyIds).toEqual(['policy-a', 'policy-b']);
|
|
188
|
+
expect(
|
|
189
|
+
needsSync(publicClient({ policies: ['policy-a', 'policy-b'] }), arrival, 'Headlamp'),
|
|
190
|
+
).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('without a precedence on every entry the array order stands', () => {
|
|
194
|
+
const partial = ranked([{ id: 'policy-b', precedence: 2 }, { id: 'policy-a' }]);
|
|
195
|
+
expect(partial.policyIds).toEqual(['policy-b', 'policy-a']);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a missing or extra policy is drift too', () => {
|
|
199
|
+
const one = ranked([{ id: 'policy-a', precedence: 1 }]);
|
|
200
|
+
expect(needsSync(publicClient({ policies: ['policy-a', 'policy-b'] }), one, 'Headlamp')).toBe(
|
|
201
|
+
true,
|
|
202
|
+
);
|
|
203
|
+
expect(needsSync(publicClient({ policies: [] }), one, 'Headlamp')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('writeBody', () => {
|
|
208
|
+
test('a public client sends the PKCE grant and the flag, and no client secret field', () => {
|
|
209
|
+
const body = writeBody(publicClient(), 'Headlamp');
|
|
210
|
+
expect(body.saasApp).toMatchObject({
|
|
211
|
+
authType: 'oidc',
|
|
212
|
+
grantTypes: ['authorization_code_with_pkce'],
|
|
213
|
+
allowPkceWithoutClientSecret: true,
|
|
214
|
+
});
|
|
215
|
+
expect(JSON.stringify(body)).not.toContain('secret":"');
|
|
216
|
+
expect(body.sessionDuration).toBe('24h');
|
|
217
|
+
expect(body.appLauncherVisible).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('the flag defaults to a confidential client', () => {
|
|
221
|
+
const props = publicClient();
|
|
222
|
+
const { allowPkceWithoutClientSecret: _flag, ...rest } = props.saasApp;
|
|
223
|
+
expect(writeBody({ ...props, saasApp: rest }, 'x').saasApp.allowPkceWithoutClientSecret).toBe(
|
|
224
|
+
false,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|