Skip to content

Commit 2a0f12c

Browse files
authored
chore: deno ci type checks and linting (#1694)
1 parent 81b510a commit 2a0f12c

6 files changed

Lines changed: 280 additions & 59 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: ZITADEL Actions Worker
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
push:
7+
branches: [main]
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
permissions: {}
14+
15+
jobs:
16+
pre-job:
17+
runs-on: ubuntu-latest
18+
permissions:
19+
contents: read
20+
outputs:
21+
should_run: ${{ steps.check.outputs.should_run }}
22+
steps:
23+
- name: Check what should run
24+
id: check
25+
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
26+
with:
27+
github-token: ${{ github.token }}
28+
filters: |
29+
worker:
30+
- 'services/zitadel-actions/**'
31+
force-filters: |
32+
- '.github/workflows/zitadel-actions.yml'
33+
34+
check:
35+
name: Check & Test
36+
needs: pre-job
37+
if: ${{ fromJSON(needs.pre-job.outputs.should_run).worker == true }}
38+
runs-on: ubuntu-latest
39+
permissions:
40+
contents: read
41+
defaults:
42+
run:
43+
working-directory: ./services/zitadel-actions
44+
steps:
45+
- name: Checkout code
46+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
47+
with:
48+
persist-credentials: false
49+
50+
- name: Setup Deno
51+
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
52+
with:
53+
deno-version: "2.8.3" # keep in sync with .mise/config.toml
54+
55+
- name: Format
56+
run: deno task fmt
57+
if: ${{ !cancelled() }}
58+
59+
- name: Lint
60+
run: deno task lint
61+
if: ${{ !cancelled() }}
62+
63+
- name: Type check
64+
run: deno task check
65+
if: ${{ !cancelled() }}
66+
67+
- name: Test
68+
run: deno task test
69+
if: ${{ !cancelled() }}
70+
71+
- name: Build (transpile)
72+
run: deno task build > /dev/null
73+
if: ${{ !cancelled() }}

services/zitadel-actions/deno.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
{
2+
"imports": {
3+
"@std/assert": "jsr:@std/assert@^1.0.19",
4+
"@deno/emit": "jsr:@deno/emit@^0.46.0"
5+
},
26
"tasks": {
37
"test": "deno test",
4-
"check": "deno check src/index.ts test/index.test.ts",
8+
"check": "deno check src/index.ts test/index.test.ts scripts/build.ts",
9+
"lint": "deno lint",
10+
"fmt": "deno fmt --check",
511
"build": "deno run --allow-read --allow-net --allow-env scripts/build.ts"
612
},
713
"compilerOptions": {

services/zitadel-actions/deno.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/zitadel-actions/scripts/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// content_base64 — Cloudflare runs JS, the source is TS. Invoked by the
33
// data.external in actions-v2.tf, so it must print ONLY a JSON object with the
44
// JS string on stdout (deno's own download/progress noise goes to stderr).
5-
import { transpile } from "jsr:@deno/emit";
5+
import { transpile } from "@deno/emit";
66

77
const entry = new URL("../src/index.ts", import.meta.url);
88
const result = await transpile(entry);

services/zitadel-actions/src/index.ts

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export interface Env {
2626
// Manipulation returned to ZITADEL. `{}` means "no change".
2727
export interface Manipulation {
2828
append_claims?: Array<{ key: string; value: unknown }>;
29-
append_attribute?: Array<{ name: string; name_format: string; value: string[] }>;
29+
append_attribute?: Array<
30+
{ name: string; name_format: string; value: string[] }
31+
>;
3032
}
3133

3234
type Handler = (body: unknown) => Manipulation | Promise<Manipulation>;
@@ -41,9 +43,17 @@ export default {
4143

4244
switch (url.pathname) {
4345
case "/idp-intent":
44-
return handleVerified(req, env.IDP_INTENT_SIGNING_KEY, (body) => handleIdpIntent(body as IdpIntentBody, env));
46+
return await handleVerified(
47+
req,
48+
env.IDP_INTENT_SIGNING_KEY,
49+
(body) => handleIdpIntent(body as IdpIntentBody, env),
50+
);
4551
case "/token":
46-
return handleVerified(req, env.TOKEN_SIGNING_KEY, (body) => handleToken(body as TokenBody, env));
52+
return await handleVerified(
53+
req,
54+
env.TOKEN_SIGNING_KEY,
55+
(body) => handleToken(body as TokenBody, env),
56+
);
4757
default:
4858
return new Response("Not found", { status: 404 });
4959
}
@@ -54,7 +64,11 @@ export default {
5464
// return value into a JSON 200. A thrown handler error becomes a 500 — with
5565
// interrupt_on_error=false on the target, ZITADEL ignores it and the login
5666
// proceeds unchanged, so a worker fault can never break authentication.
57-
async function handleVerified(req: Request, signingKey: string, handler: Handler): Promise<Response> {
67+
async function handleVerified(
68+
req: Request,
69+
signingKey: string,
70+
handler: Handler,
71+
): Promise<Response> {
5872
if (!signingKey) {
5973
console.error("[init] missing signing key binding");
6074
return new Response("Missing configuration", { status: 500 });
@@ -98,7 +112,10 @@ interface IdpIntentBody {
98112
response?: { userId?: string; idpInformation?: IdpInformation };
99113
}
100114

101-
async function handleIdpIntent(body: IdpIntentBody, env: Env): Promise<Manipulation> {
115+
async function handleIdpIntent(
116+
body: IdpIntentBody,
117+
env: Env,
118+
): Promise<Manipulation> {
102119
const idp = body?.response?.idpInformation;
103120
const userId = body?.response?.userId;
104121
if (!idp || !userId) {
@@ -114,12 +131,16 @@ async function handleIdpIntent(body: IdpIntentBody, env: Env): Promise<Manipulat
114131
if (idp.idpId === env.GITHUB_IDP_ID) {
115132
// GitHub: primary email comes from /user/emails (profile email may be null
116133
// when the user keeps it private); name split mirrors the old v1 action.
117-
email = (await githubPrimaryEmail(idp?.oauth?.accessToken)) ?? raw.email ?? null;
134+
email = (await githubPrimaryEmail(idp?.oauth?.accessToken)) ?? raw.email ??
135+
null;
118136
({ firstName, lastName } = splitName(raw.login ?? idp.userName, raw.name));
119137
} else if (idp.idpId === env.GITLAB_IDP_ID) {
120138
// GitLab is OIDC — email + name are already in the userinfo.
121139
email = raw.email ?? null;
122-
({ firstName, lastName } = splitName(raw.nickname ?? raw.preferred_username ?? idp.userName, raw.name));
140+
({ firstName, lastName } = splitName(
141+
raw.nickname ?? raw.preferred_username ?? idp.userName,
142+
raw.name,
143+
));
123144
} else {
124145
console.log(`[idp-intent] idpId ${idp.idpId} not handled — skipping`);
125146
return {};
@@ -135,7 +156,9 @@ async function handleIdpIntent(body: IdpIntentBody, env: Env): Promise<Manipulat
135156
return {};
136157
}
137158

138-
async function githubPrimaryEmail(accessToken: string | undefined): Promise<string | null> {
159+
async function githubPrimaryEmail(
160+
accessToken: string | undefined,
161+
): Promise<string | null> {
139162
if (!accessToken) return null;
140163
const res = await fetch("https://api.github.com/user/emails", {
141164
headers: {
@@ -148,13 +171,18 @@ async function githubPrimaryEmail(accessToken: string | undefined): Promise<stri
148171
console.warn(`[github] /user/emails -> ${res.status}`);
149172
return null;
150173
}
151-
const emails = (await res.json()) as Array<{ email: string; primary: boolean }>;
174+
const emails = (await res.json()) as Array<
175+
{ email: string; primary: boolean }
176+
>;
152177
return emails.find((e) => e.primary)?.email ?? null;
153178
}
154179

155180
// Mirror the old v1 action's name split: first word is given name, the rest is
156181
// family name (defaulting to a single space, which ZITADEL requires non-empty).
157-
export function splitName(login: string | undefined, name: unknown): { firstName: string; lastName: string } {
182+
export function splitName(
183+
login: string | undefined,
184+
name: unknown,
185+
): { firstName: string; lastName: string } {
158186
let firstName = login ?? "";
159187
let lastName = " ";
160188
const parts = String(name ?? "").trim().split(" ");
@@ -170,27 +198,32 @@ async function updateHumanUser(
170198
firstName: string,
171199
lastName: string,
172200
): Promise<void> {
173-
const res = await fetch(`https://${env.ZITADEL_DOMAIN}/v2/users/human/${userId}`, {
174-
method: "PUT",
175-
headers: {
176-
authorization: `Bearer ${env.ZITADEL_TOKEN}`,
177-
"content-type": "application/json",
178-
},
179-
body: JSON.stringify({
180-
// displayName isn't auto-derived on update (only at creation), so set it.
181-
profile: {
182-
givenName: firstName,
183-
familyName: lastName,
184-
displayName: `${firstName} ${lastName}`,
201+
const res = await fetch(
202+
`https://${env.ZITADEL_DOMAIN}/v2/users/human/${userId}`,
203+
{
204+
method: "PUT",
205+
headers: {
206+
authorization: `Bearer ${env.ZITADEL_TOKEN}`,
207+
"content-type": "application/json",
185208
},
186-
email: { email, isVerified: true },
187-
}),
188-
});
209+
body: JSON.stringify({
210+
// displayName isn't auto-derived on update (only at creation), so set it.
211+
profile: {
212+
givenName: firstName,
213+
familyName: lastName,
214+
displayName: `${firstName} ${lastName}`,
215+
},
216+
email: { email, isVerified: true },
217+
}),
218+
},
219+
);
189220
const text = await res.text();
190221
if (!res.ok) {
191222
throw new Error(`UpdateHumanUser ${userId} -> ${res.status} ${text}`);
192223
}
193-
console.log(`[idp-intent] updated ${userId} email=${email} name="${firstName} ${lastName}"`);
224+
console.log(
225+
`[idp-intent] updated ${userId} email=${email} name="${firstName} ${lastName}"`,
226+
);
194227
}
195228

196229
// --- /token : preuserinfo (roles) + presamlresponse (saml) ---------------
@@ -214,7 +247,7 @@ async function handleToken(body: TokenBody, env: Env): Promise<Manipulation> {
214247
case "function/preuserinfo":
215248
return mapRoles(body);
216249
case "function/presamlresponse":
217-
return mapSamlRoles(body, env);
250+
return await mapSamlRoles(body, env);
218251
default:
219252
console.log(`[token] unhandled function ${body?.function}`);
220253
return {};
@@ -240,7 +273,9 @@ export function mapRoles(body: TokenBody): Manipulation {
240273
.filter(Boolean)[0];
241274
if (!projectId) return {};
242275

243-
const roles = grants.filter((g) => g.project_id === projectId).map((g) => g.roles);
276+
const roles = grants.filter((g) => g.project_id === projectId).map((g) =>
277+
g.roles
278+
);
244279
if (roles.length === 1) {
245280
return { append_claims: [{ key: "role", value: String(roles[0]) }] };
246281
}
@@ -254,7 +289,10 @@ export function mapRoles(body: TokenBody): Manipulation {
254289
// SAML attribute — matching the v1 action. Unlike preuserinfo, the
255290
// presamlresponse payload carries no grants (only `user`), so fetch them from
256291
// the management API by user id.
257-
export async function mapSamlRoles(body: TokenBody, env: Env): Promise<Manipulation> {
292+
export async function mapSamlRoles(
293+
body: TokenBody,
294+
env: Env,
295+
): Promise<Manipulation> {
258296
const userId = body?.user?.id;
259297
if (!userId) return {};
260298

@@ -272,12 +310,18 @@ export async function mapSamlRoles(body: TokenBody, env: Env): Promise<Manipulat
272310
if (!res.ok) {
273311
// Loud: a failure here means the SAML assertion ships with no Roles, which
274312
// can silently drop SP-side access (e.g. OVHCloud admin).
275-
console.error(`[saml] grants search failed for ${userId}: ${res.status} — assertion will have no Roles`);
313+
console.error(
314+
`[saml] grants search failed for ${userId}: ${res.status} — assertion will have no Roles`,
315+
);
276316
return {};
277317
}
278318

279-
const data = (await res.json()) as { result?: Array<{ roleKeys?: string[] }> };
280-
const roles = [...new Set((data.result ?? []).flatMap((g) => g.roleKeys ?? []))];
319+
const data = (await res.json()) as {
320+
result?: Array<{ roleKeys?: string[] }>;
321+
};
322+
const roles = [
323+
...new Set((data.result ?? []).flatMap((g) => g.roleKeys ?? [])),
324+
];
281325
if (roles.length === 0) return {};
282326

283327
return {
@@ -297,14 +341,21 @@ export async function mapSamlRoles(body: TokenBody, env: Env): Promise<Manipulat
297341
// outside this window so a captured request can't be replayed indefinitely.
298342
const SIGNATURE_TOLERANCE_SECONDS = 300;
299343

300-
export async function verifySignature(signatureHeader: string, rawBody: string, signingKey: string): Promise<boolean> {
344+
export async function verifySignature(
345+
signatureHeader: string,
346+
rawBody: string,
347+
signingKey: string,
348+
): Promise<boolean> {
301349
const parts = signatureHeader.split(",");
302350
const timestamp = parts.find((e) => e.startsWith("t="))?.slice(2);
303351
const signature = parts.find((e) => e.startsWith("v1="))?.slice(3);
304352
if (!timestamp || !signature) return false;
305353

306354
const ts = Number(timestamp);
307-
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > SIGNATURE_TOLERANCE_SECONDS) {
355+
if (
356+
!Number.isFinite(ts) ||
357+
Math.abs(Date.now() / 1000 - ts) > SIGNATURE_TOLERANCE_SECONDS
358+
) {
308359
console.warn(`[verify] timestamp outside tolerance (t=${timestamp})`);
309360
return false;
310361
}
@@ -317,7 +368,11 @@ export async function verifySignature(signatureHeader: string, rawBody: string,
317368
false,
318369
["sign"],
319370
);
320-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${rawBody}`));
371+
const sig = await crypto.subtle.sign(
372+
"HMAC",
373+
key,
374+
encoder.encode(`${timestamp}.${rawBody}`),
375+
);
321376
const computed = Array.from(new Uint8Array(sig))
322377
.map((b) => b.toString(16).padStart(2, "0"))
323378
.join("");

0 commit comments

Comments
 (0)