@homeflare/alchemy 0.48.1 → 0.50.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/kubernetes/helm-chart-bounded.d.ts +44 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
- package/dist/kubernetes/index.d.ts +12 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +585 -0
- package/dist/kubernetes/index.js.map +19 -0
- package/dist/kubernetes/ready-checks.d.ts +45 -0
- package/dist/kubernetes/ready-checks.d.ts.map +1 -0
- package/dist/kubernetes/ready-classify.d.ts +23 -0
- package/dist/kubernetes/ready-classify.d.ts.map +1 -0
- package/dist/kubernetes/ready-connect.d.ts +29 -0
- package/dist/kubernetes/ready-connect.d.ts.map +1 -0
- package/dist/kubernetes/ready-declare.d.ts +5 -0
- package/dist/kubernetes/ready-declare.d.ts.map +1 -0
- package/dist/kubernetes/ready-errors.d.ts +80 -0
- package/dist/kubernetes/ready-errors.d.ts.map +1 -0
- package/dist/kubernetes/ready-poll.d.ts +61 -0
- package/dist/kubernetes/ready-poll.d.ts.map +1 -0
- package/dist/kubernetes/ready.d.ts +74 -0
- package/dist/kubernetes/ready.d.ts.map +1 -0
- package/dist/talos/fake-apiserver.d.ts +11 -2
- package/dist/talos/fake-apiserver.d.ts.map +1 -1
- package/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/kubernetes-ready.md +146 -0
- package/docs/ownership.md +3 -2
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/saas-oidc.md +185 -0
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +7 -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/kubernetes/helm-chart-bounded.test.ts +242 -0
- package/src/kubernetes/helm-chart-bounded.ts +110 -0
- package/src/kubernetes/index.ts +28 -0
- package/src/kubernetes/ready-backoff.test.ts +124 -0
- package/src/kubernetes/ready-checks.test.ts +106 -0
- package/src/kubernetes/ready-checks.ts +165 -0
- package/src/kubernetes/ready-classify.ts +76 -0
- package/src/kubernetes/ready-connect.ts +52 -0
- package/src/kubernetes/ready-declare.test.ts +85 -0
- package/src/kubernetes/ready-declare.ts +50 -0
- package/src/kubernetes/ready-errors.ts +102 -0
- package/src/kubernetes/ready-first-install.test.ts +126 -0
- package/src/kubernetes/ready-poll-states.test.ts +92 -0
- package/src/kubernetes/ready-poll.test.ts +194 -0
- package/src/kubernetes/ready-poll.ts +215 -0
- package/src/kubernetes/ready-rollout.test.ts +58 -0
- package/src/kubernetes/ready-secrecy.test.ts +131 -0
- package/src/kubernetes/ready.fixtures.ts +103 -0
- package/src/kubernetes/ready.test.ts +205 -0
- package/src/kubernetes/ready.ts +188 -0
- package/src/talos/fake-apiserver.ts +21 -3
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HomeFlare.Access.SaasOidcApplication` — the pure helpers the lifecycle uses: validation, the
|
|
3
|
+
* issuer URLs, drift, the write body and the attribute mapping. The props and attributes types are
|
|
4
|
+
* declared in saas-oidc-props.ts and re-exported here.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE ATTRIBUTES NEVER HOLD THE CLIENT SECRET, and nothing here can: `ObservedApp`
|
|
7
|
+
* (saas-oidc-api.ts) has no field for it. A client that authenticates with PKCE alone declares
|
|
8
|
+
* `allowPkceWithoutClientSecret: true`, and then no secret exists to keep.
|
|
9
|
+
* ★ THE ISSUER IS `https://<teamDomain>/cdn-cgi/access/sso/oidc/<clientId>`. The team domain is a
|
|
10
|
+
* prop; the openbao copy this came from hardcoded one account's. Cloudflare also reports the
|
|
11
|
+
* same host in the app's own `domain` field (Alchemy's `Access.Application` doc: for `saas`
|
|
12
|
+
* "Cloudflare uses the OIDC issuer"), and `checkTeam` refuses a declaration that disagrees with it.
|
|
13
|
+
*/
|
|
14
|
+
import { SaasOidcError } from './saas-oidc-error.ts';
|
|
15
|
+
import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-props.ts';
|
|
16
|
+
import type { AppWrite, ObservedApp } from './saas-oidc-wire.ts';
|
|
17
|
+
|
|
18
|
+
// The declared shape lives in saas-oidc-props.ts (every field documented there); re-exported so
|
|
19
|
+
// the module that always held it keeps answering to its old name.
|
|
20
|
+
export type {
|
|
21
|
+
OidcGrantType,
|
|
22
|
+
OidcScope,
|
|
23
|
+
SaasOidcApplicationAttributes,
|
|
24
|
+
SaasOidcApplicationProps,
|
|
25
|
+
} from './saas-oidc-props.ts';
|
|
26
|
+
|
|
27
|
+
const HOSTNAME = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
|
|
28
|
+
|
|
29
|
+
/** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
|
|
30
|
+
export const validateSaasOidc = (props: SaasOidcApplicationProps): SaasOidcError | undefined => {
|
|
31
|
+
const fail = (message: string) => new SaasOidcError({ message });
|
|
32
|
+
if (typeof props.teamDomain !== 'string' || !HOSTNAME.test(props.teamDomain)) {
|
|
33
|
+
return fail(
|
|
34
|
+
`SaasOidcApplication \`teamDomain\` must be a bare lowercase hostname such as "example.cloudflareaccess.com" (no scheme, no path); got ${JSON.stringify(props.teamDomain)}.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (props.name !== undefined && props.name.trim() === '') {
|
|
38
|
+
return fail('SaasOidcApplication `name`, when set, must not be empty.');
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
props.saasApp.allowPkceWithoutClientSecret === true &&
|
|
42
|
+
!props.saasApp.grantTypes.includes('authorization_code_with_pkce')
|
|
43
|
+
) {
|
|
44
|
+
return fail(
|
|
45
|
+
'`allowPkceWithoutClientSecret: true` only applies to the `authorization_code_with_pkce` grant; add it to `grantTypes` or drop the flag.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const issuerFor = (teamDomain: string, clientId: string): string =>
|
|
52
|
+
`https://${teamDomain}/cdn-cgi/access/sso/oidc/${clientId}`;
|
|
53
|
+
|
|
54
|
+
const hostOf = (domain: string): string => domain.split('/')[0]?.toLowerCase() ?? '';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ A DECLARED TEAM DOMAIN THAT DISAGREES WITH THE APP'S OWN `domain` is refused: the issuer in
|
|
58
|
+
* the attributes would point at a different team than the one that holds the app.
|
|
59
|
+
* ⛔ WHEN CLOUDFLARE REPORTS NO `domain` there is nothing to compare, so the team the row already
|
|
60
|
+
* recorded (`knownTeam`, from state) stands in: a declaration that moves it is refused, because
|
|
61
|
+
* `issuer` is a declared stable and must not move under the row. With no `knownTeam` either
|
|
62
|
+
* (a first create or a cold adoption) there is nothing to compare against and the declaration
|
|
63
|
+
* stands.
|
|
64
|
+
*/
|
|
65
|
+
export const checkTeam = (
|
|
66
|
+
teamDomain: string,
|
|
67
|
+
app: ObservedApp,
|
|
68
|
+
knownTeam?: string,
|
|
69
|
+
): SaasOidcError | undefined => {
|
|
70
|
+
if (app.domain === undefined) {
|
|
71
|
+
return knownTeam === undefined || knownTeam.toLowerCase() === teamDomain.toLowerCase()
|
|
72
|
+
? undefined
|
|
73
|
+
: new SaasOidcError({
|
|
74
|
+
message: `SaasOidcApplication declares teamDomain "${teamDomain}" but the row recorded "${knownTeam}" and Cloudflare reports no domain for the app to confirm the change. The issuer cannot move; revert the declaration.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return hostOf(app.domain) === teamDomain.toLowerCase()
|
|
78
|
+
? undefined
|
|
79
|
+
: new SaasOidcError({
|
|
80
|
+
message: `SaasOidcApplication declares teamDomain "${teamDomain}" but Cloudflare reports the app on "${hostOf(app.domain)}".`,
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const refreshLifetime = (props: SaasOidcApplicationProps): string =>
|
|
85
|
+
props.saasApp.refreshTokenLifetime ?? '';
|
|
86
|
+
|
|
87
|
+
/** The request body for a create or an update. */
|
|
88
|
+
export const writeBody = (news: SaasOidcApplicationProps, name: string): AppWrite => ({
|
|
89
|
+
name,
|
|
90
|
+
sessionDuration: news.sessionDuration ?? '24h',
|
|
91
|
+
allowedIdps: news.allowedIdps,
|
|
92
|
+
autoRedirectToIdentity: news.autoRedirectToIdentity,
|
|
93
|
+
appLauncherVisible: news.appLauncherVisible ?? false,
|
|
94
|
+
policies: news.policies,
|
|
95
|
+
saasApp: {
|
|
96
|
+
authType: 'oidc',
|
|
97
|
+
redirectUris: news.saasApp.redirectUris,
|
|
98
|
+
scopes: news.saasApp.scopes,
|
|
99
|
+
grantTypes: news.saasApp.grantTypes,
|
|
100
|
+
accessTokenLifetime: news.saasApp.accessTokenLifetime,
|
|
101
|
+
...(news.saasApp.refreshTokenLifetime === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { refreshTokenOptions: { lifetime: news.saasApp.refreshTokenLifetime } }),
|
|
104
|
+
allowPkceWithoutClientSecret: news.saasApp.allowPkceWithoutClientSecret ?? false,
|
|
105
|
+
appLauncherUrl: news.saasApp.appLauncherUrl,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const sameSet = (a: ReadonlyArray<string>, b: ReadonlyArray<string> | undefined): boolean =>
|
|
110
|
+
JSON.stringify([...a].sort()) === JSON.stringify([...(b ?? [])].sort());
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ⛔ ORDER MATTERS FOR POLICIES: `policies` is "in ascending order of precedence" (the SDK's own
|
|
114
|
+
* field doc, zero_trust.ts:9774), so `[a, b]` and `[b, a]` are different declarations. A set
|
|
115
|
+
* compare here would let a reorder go unnoticed and leave the live precedence unchanged.
|
|
116
|
+
* `parseApp` hands the observed ids over already ordered by the API's `precedence`.
|
|
117
|
+
*/
|
|
118
|
+
const sameList = (a: ReadonlyArray<string>, b: ReadonlyArray<string> | undefined): boolean => {
|
|
119
|
+
const other = b ?? [];
|
|
120
|
+
return a.length === other.length && a.every((item, index) => item === other[index]);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Does the live app differ from the declaration? Compared: name, auth type, redirect URIs, scopes,
|
|
125
|
+
* grant types (each as a set), both lifetimes, the PKCE flag and the policies IN ORDER — the
|
|
126
|
+
* openbao copy's set plus the policy order, kept so adopting a live app is not newly a write.
|
|
127
|
+
* ⚠️ NOT COMPARED: `sessionDuration`, `allowedIdps`, `autoRedirectToIdentity`, `appLauncherVisible`,
|
|
128
|
+
* `appLauncherUrl`. They are written on create and on any sync, but a change to only those does
|
|
129
|
+
* not trigger one. Carried over unchanged; see saas-oidc.md.
|
|
130
|
+
*/
|
|
131
|
+
export const needsSync = (
|
|
132
|
+
news: SaasOidcApplicationProps,
|
|
133
|
+
observed: ObservedApp,
|
|
134
|
+
name: string,
|
|
135
|
+
): boolean => {
|
|
136
|
+
const saas = observed.saasApp;
|
|
137
|
+
return (
|
|
138
|
+
(observed.name ?? '') !== name ||
|
|
139
|
+
(saas?.authType ?? '') !== 'oidc' ||
|
|
140
|
+
!sameSet(news.saasApp.redirectUris, saas?.redirectUris) ||
|
|
141
|
+
!sameSet(news.saasApp.scopes, saas?.scopes) ||
|
|
142
|
+
!sameSet(news.saasApp.grantTypes, saas?.grantTypes) ||
|
|
143
|
+
(saas?.accessTokenLifetime ?? '') !== news.saasApp.accessTokenLifetime ||
|
|
144
|
+
refreshLifetime(news) !== (saas?.refreshTokenLifetime ?? '') ||
|
|
145
|
+
(saas?.allowPkceWithoutClientSecret ?? false) !==
|
|
146
|
+
(news.saasApp.allowPkceWithoutClientSecret ?? false) ||
|
|
147
|
+
!sameList(news.policies, observed.policyIds)
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The attributes of a live app, or `undefined` when it lacks an id, an `aud` or a client id (so it
|
|
153
|
+
* is not a SaaS OIDC app this package can describe). `teamDomain` is the declared one, or, for a
|
|
154
|
+
* read that has no declaration (state written before the prop existed), the host Cloudflare reports.
|
|
155
|
+
*/
|
|
156
|
+
export const toAttributes = (
|
|
157
|
+
app: ObservedApp,
|
|
158
|
+
accountId: string,
|
|
159
|
+
fallbackName: string,
|
|
160
|
+
declaredTeam: string | undefined,
|
|
161
|
+
): SaasOidcApplicationAttributes | undefined => {
|
|
162
|
+
const clientId = app.saasApp?.clientId;
|
|
163
|
+
const teamDomain = declaredTeam ?? (app.domain === undefined ? undefined : hostOf(app.domain));
|
|
164
|
+
if (app.id === undefined || app.aud === undefined || clientId === undefined) return undefined;
|
|
165
|
+
if (teamDomain === undefined || teamDomain === '') return undefined;
|
|
166
|
+
const issuer = issuerFor(teamDomain, clientId);
|
|
167
|
+
const saas = app.saasApp;
|
|
168
|
+
return {
|
|
169
|
+
applicationId: app.id,
|
|
170
|
+
aud: app.aud,
|
|
171
|
+
domain: app.domain ?? issuer.replace('https://', ''),
|
|
172
|
+
name: app.name ?? fallbackName,
|
|
173
|
+
accountId,
|
|
174
|
+
teamDomain,
|
|
175
|
+
clientId,
|
|
176
|
+
issuer,
|
|
177
|
+
authorizationEndpoint: `${issuer}/authorization`,
|
|
178
|
+
tokenEndpoint: `${issuer}/token`,
|
|
179
|
+
jwksEndpoint: `${issuer}/jwks`,
|
|
180
|
+
userinfoEndpoint: `${issuer}/userinfo`,
|
|
181
|
+
configurationEndpoint: `${issuer}/.well-known/openid-configuration`,
|
|
182
|
+
redirectUris: saas?.redirectUris ?? [],
|
|
183
|
+
scopes: saas?.scopes ?? [],
|
|
184
|
+
grantTypes: saas?.grantTypes ?? [],
|
|
185
|
+
accessTokenLifetime: saas?.accessTokenLifetime ?? '',
|
|
186
|
+
refreshTokenLifetime: saas?.refreshTokenLifetime ?? '',
|
|
187
|
+
createdAt: app.createdAt,
|
|
188
|
+
updatedAt: app.updatedAt,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaasOidcApplicationProvider's handlers, called the way the engine calls them, over the fake
|
|
3
|
+
* Access API in fake-access.ts — and `engine`, which runs Alchemy's real plan/apply over the same
|
|
4
|
+
* fake and exposes the in-memory state rows. Shared by saas-oidc.test.ts and
|
|
5
|
+
* saas-oidc-state.test.ts. ⛔ TEST-ONLY, like fake-access.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { apply } from 'alchemy/Apply';
|
|
8
|
+
import * as Plan from 'alchemy/Plan';
|
|
9
|
+
import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
|
|
10
|
+
import { Stage } from 'alchemy/Stage';
|
|
11
|
+
import * as State from 'alchemy/State';
|
|
12
|
+
import * as Cause from 'effect/Cause';
|
|
13
|
+
import * as Context from 'effect/Context';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Exit from 'effect/Exit';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as FetchHttpClient from 'effect/http/FetchHttpClient';
|
|
18
|
+
import { type FakeAccess, TEAM } from './fake-access.ts';
|
|
19
|
+
import { fakeProviderLayer } from './fake-mesh.ts';
|
|
20
|
+
import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
|
|
21
|
+
import { SaasOidcApplication, SaasOidcApplicationProvider } from './saas-oidc.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ⚠️ THE SDK'S STRICT VALIDATION SWITCH, REBUILT BY KEY. A `CloudflareParseError` exists only under
|
|
25
|
+
* `ResponseValidation.strict` (`@distilled.cloud/core` response-validation.ts:43-52, :104-116);
|
|
26
|
+
* the default is lenient. `@distilled.cloud/core` is not a dependency of this package, so this
|
|
27
|
+
* declares a reference with the SAME string key and provides `'strict'` for it: the SDK reads the
|
|
28
|
+
* mode from the fiber's context by that key. saas-oidc-parse-error.test.ts first proves the raw
|
|
29
|
+
* SDK really fails under it, so a renamed key fails that test instead of passing vacuously.
|
|
30
|
+
*/
|
|
31
|
+
const ResponseValidation = Context.Reference<'lenient' | 'strict'>(
|
|
32
|
+
'@distilled.cloud/core/ResponseValidation',
|
|
33
|
+
{ defaultValue: () => 'lenient' },
|
|
34
|
+
);
|
|
35
|
+
export const strict = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
36
|
+
effect.pipe(Effect.provideService(ResponseValidation, 'strict'));
|
|
37
|
+
|
|
38
|
+
export const ids = { fqn: 'stack/headlamp', id: 'headlamp', instanceId: 'i-1' };
|
|
39
|
+
export const extra = { bindings: [] as never, session: undefined as never };
|
|
40
|
+
|
|
41
|
+
export type P = Effect.Success<typeof SaasOidcApplication.Provider>;
|
|
42
|
+
|
|
43
|
+
/** A public PKCE client, the shape Headlamp declares (Q6). */
|
|
44
|
+
export const publicClient = (
|
|
45
|
+
over: Partial<SaasOidcApplicationProps> = {},
|
|
46
|
+
): SaasOidcApplicationProps => ({
|
|
47
|
+
name: 'Headlamp',
|
|
48
|
+
teamDomain: TEAM,
|
|
49
|
+
policies: ['policy-admin'],
|
|
50
|
+
saasApp: {
|
|
51
|
+
authType: 'oidc',
|
|
52
|
+
redirectUris: ['https://headlamp.example.test/oidc-callback'],
|
|
53
|
+
scopes: ['openid', 'email', 'groups'],
|
|
54
|
+
grantTypes: ['authorization_code_with_pkce'],
|
|
55
|
+
accessTokenLifetime: '15m',
|
|
56
|
+
allowPkceWithoutClientSecret: true,
|
|
57
|
+
},
|
|
58
|
+
...over,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The wire `saas_app` of a live app that matches `publicClient()` exactly (no drift to sync). */
|
|
62
|
+
export const LIVE_OIDC = {
|
|
63
|
+
auth_type: 'oidc',
|
|
64
|
+
redirect_uris: ['https://headlamp.example.test/oidc-callback'],
|
|
65
|
+
scopes: ['openid', 'email', 'groups'],
|
|
66
|
+
grant_types: ['authorization_code_with_pkce'],
|
|
67
|
+
access_token_lifetime: '15m',
|
|
68
|
+
allow_pkce_without_client_secret: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Build the provider over the fake and hand it to `use`. */
|
|
72
|
+
export const run = <A, E>(fake: FakeAccess, use: (p: P) => Effect.Effect<A, E, never>) =>
|
|
73
|
+
Effect.runPromise(
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
return yield* use(yield* SaasOidcApplication.Provider);
|
|
76
|
+
}).pipe(
|
|
77
|
+
Effect.provide(
|
|
78
|
+
SaasOidcApplicationProvider().pipe(Layer.provideMerge(fakeProviderLayer(fake))),
|
|
79
|
+
),
|
|
80
|
+
// `createPhysicalName` reads these from the engine's context; a named app never calls it.
|
|
81
|
+
Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
|
|
82
|
+
) as Effect.Effect<A, E, never>,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
export const reconcile = (
|
|
86
|
+
p: P,
|
|
87
|
+
news: SaasOidcApplicationProps,
|
|
88
|
+
output?: SaasOidcApplicationAttributes,
|
|
89
|
+
) => p.reconcile({ ...ids, ...extra, news, olds: undefined, output });
|
|
90
|
+
|
|
91
|
+
export const read = (
|
|
92
|
+
p: P,
|
|
93
|
+
olds: SaasOidcApplicationProps,
|
|
94
|
+
output?: SaasOidcApplicationAttributes,
|
|
95
|
+
) => {
|
|
96
|
+
const handler = p.read;
|
|
97
|
+
if (handler === undefined) throw new Error('provider has no read handler');
|
|
98
|
+
return handler({ ...ids, olds, output });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The writes the fake saw, in order: `['POST', 'PUT', …]`. */
|
|
102
|
+
export const writes = (fake: FakeAccess) =>
|
|
103
|
+
fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
|
|
104
|
+
|
|
105
|
+
type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ★ THE REAL ENGINE, NOT A MODEL OF IT: `Plan.make` + `apply` over an in-memory state store, as
|
|
109
|
+
* mesh-node-harness.ts does. `rows` is that store, so a test can serialise exactly what Alchemy
|
|
110
|
+
* would persist.
|
|
111
|
+
*/
|
|
112
|
+
export const engine = (fake: FakeAccess, extra: Layer.Layer<never, never, never> = Layer.empty) => {
|
|
113
|
+
const rows: Rows = {};
|
|
114
|
+
const state = Layer.succeed(State.State, State.InMemoryService(rows));
|
|
115
|
+
const providers = Layer.mergeAll(
|
|
116
|
+
SaasOidcApplicationProvider().pipe(Layer.provide(fakeProviderLayer(fake))),
|
|
117
|
+
extra,
|
|
118
|
+
);
|
|
119
|
+
const deploy = <A, E, R>(declare: Effect.Effect<A, E, R>) =>
|
|
120
|
+
Effect.runPromiseExit(
|
|
121
|
+
declare.pipe(
|
|
122
|
+
makeStack({ name: 'oidc', providers, state }) as never,
|
|
123
|
+
Effect.flatMap((compiled: CompiledStack) =>
|
|
124
|
+
Plan.make(compiled).pipe(Effect.flatMap(apply), Effect.provide(compiled.services)),
|
|
125
|
+
),
|
|
126
|
+
Effect.provide(Layer.succeed(Stage, 'test')),
|
|
127
|
+
Effect.scoped,
|
|
128
|
+
Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
|
|
129
|
+
) as Effect.Effect<unknown, unknown>,
|
|
130
|
+
);
|
|
131
|
+
return {
|
|
132
|
+
deploy,
|
|
133
|
+
rows,
|
|
134
|
+
status: (fqn: string) => rows['oidc']?.['test']?.[fqn]?.status,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** The failure sentence of a deploy, or `''` when it succeeded. */
|
|
139
|
+
export const failureOf = (exit: Exit.Exit<unknown, unknown>) =>
|
|
140
|
+
Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
|