assay-lua 0.10.2

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
--- @module assay.zitadel
--- @description Zitadel OIDC identity management. Projects, OIDC apps, IdPs, users, login policies.
--- @keywords zitadel, oidc, identity, projects, applications, idp, users, authentication, domain, app, login-policy, user, password, google, machine-key, jwt, saml
--- @quickref c.domains:ensure_primary(domain) -> bool | Set organization primary domain
--- @quickref c.projects:find(name) -> project|nil | Find project by name
--- @quickref c.projects:create(name, opts?) -> project | Create a new project
--- @quickref c.projects:ensure(name, opts?) -> project | Ensure project exists
--- @quickref c.apps:find(project_id, name) -> app|nil | Find OIDC app by name
--- @quickref c.apps:create_oidc(project_id, opts) -> app | Create OIDC application
--- @quickref c.apps:ensure_oidc(project_id, opts) -> app | Ensure OIDC app exists
--- @quickref c.idps:find(name) -> idp|nil | Find identity provider by name
--- @quickref c.idps:ensure_google(opts) -> idp_id|nil | Ensure Google IdP exists
--- @quickref c.idps:ensure_oidc(opts) -> idp_id|nil | Ensure generic OIDC IdP exists
--- @quickref c.idps:add_to_login_policy(idp_id) -> bool | Add IdP to login policy
--- @quickref c.users:search(query) -> [user] | Search users
--- @quickref c.users:update_email(user_id, email) -> bool | Update user email
--- @quickref c.login_policy:get() -> policy|nil | Get login policy
--- @quickref c.login_policy:update(policy) -> bool | Update login policy
--- @quickref c.login_policy:disable_password() -> bool | Disable password-based login

local M = {}

function M.client(opts)
  opts = opts or {}
  local url = opts.url
  local domain = opts.domain
  assert.not_nil(url, "zitadel.client: url required")
  assert.not_nil(domain, "zitadel.client: domain required")

  local base_url = url:gsub("/+$", "")
  local host_header = "auth." .. domain
  local access_token = nil

  -- Private: authenticate via machine key JWT
  local function authenticate(key_data)
    -- key_data: { userId, key, keyId } -- from machine key JSON
    local now = time()
    local claims = {
      iss = key_data.userId,
      sub = key_data.userId,
      aud = "https://auth." .. domain,
      iat = now,
      exp = now + 300,
    }
    local jwt_token = crypto.jwt_sign(claims, key_data.key, "RS256", { kid = key_data.keyId })

    local token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"
      .. "&scope=openid+urn%3Azitadel%3Aiam%3Aorg%3Aproject%3Aid%3Azitadel%3Aaud"
      .. "&assertion=" .. jwt_token
    local resp = http.post(base_url .. "/oauth/v2/token", token_body, {
      headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Host"] = host_header },
    })
    if resp.status ~= 200 then
      error("zitadel: token exchange failed (HTTP " .. resp.status .. "): " .. resp.body)
    end
    local data = json.parse(resp.body)
    assert.not_nil(data.access_token, "zitadel: no access_token in token response")
    access_token = data.access_token
    return access_token
  end

  -- Authenticate from machine key data (table) or file path (string)
  if opts.machine_key then
    authenticate(opts.machine_key)
  elseif opts.machine_key_file then
    local key_json = fs.read(opts.machine_key_file)
    local key_data = json.parse(key_json)
    assert.not_nil(key_data.userId, "zitadel: machine key missing userId")
    assert.not_nil(key_data.key, "zitadel: machine key missing key")
    assert.not_nil(key_data.keyId, "zitadel: machine key missing keyId")
    authenticate(key_data)
  elseif opts.token then
    access_token = opts.token
  else
    error("zitadel.client: one of machine_key, machine_key_file, or token required")
  end

  -- Shared HTTP helpers (captured by all sub-object methods as upvalues)

  local function headers()
    return {
      ["Authorization"] = "Bearer " .. access_token,
      ["Content-Type"] = "application/json",
      ["Host"] = host_header,
    }
  end

  local function api_get(path)
    local resp = http.get(base_url .. path, { headers = headers() })
    return resp
  end

  local function api_post(path, body)
    local resp = http.post(base_url .. path, body or "{}", { headers = headers() })
    return resp
  end

  local function api_put(path, body)
    local resp = http.put(base_url .. path, body or "{}", { headers = headers() })
    return resp
  end

  -- ===== Client =====

  -- Expose some fields for test assertions
  local c = {
    url = base_url,
    domain = domain,
    host_header = host_header,
    access_token = access_token,
  }

  -- ===== Domains =====

  c.domains = {}

  function c.domains:ensure_primary(target_domain)
    local resp = api_get("/admin/v1/orgs/me/domains")
    if resp.status ~= 200 then
      log.warn("zitadel: could not list org domains (HTTP " .. resp.status .. ")")
      return false
    end
    local data = json.parse(resp.body)
    if data.result then
      for _, d in ipairs(data.result) do
        if d.domainName == target_domain and d.isPrimary then
          log.info("Org primary domain already set to " .. target_domain)
          return true
        end
      end
    end
    -- Add domain (may already exist -- 409 is OK)
    local add_resp = api_post("/admin/v1/orgs/me/domains", { domain = target_domain })
    if add_resp.status ~= 200 and add_resp.status ~= 409 then
      log.warn("zitadel: could not add domain (HTTP " .. add_resp.status .. ")")
      return false
    end
    local primary_resp = api_post("/admin/v1/orgs/me/domains/" .. target_domain .. "/_set_primary", {})
    if primary_resp.status == 200 then
      log.info("Set org primary domain to " .. target_domain)
      return true
    end
    log.warn("zitadel: could not set primary domain (HTTP " .. primary_resp.status .. ")")
    return false
  end

  -- ===== Projects =====

  c.projects = {}

  function c.projects:find(name)
    local resp = api_post("/management/v1/projects/_search", {
      queries = { { nameQuery = { name = name, method = "TEXT_QUERY_METHOD_EQUALS" } } },
    })
    if resp.status ~= 200 then return nil end
    local data = json.parse(resp.body)
    if data.result and #data.result > 0 then
      return data.result[1]
    end
    return nil
  end

  function c.projects:create(name, opts_proj)
    opts_proj = opts_proj or {}
    local body = { name = name }
    if opts_proj.projectRoleAssertion ~= nil then
      body.projectRoleAssertion = opts_proj.projectRoleAssertion
    end
    local resp = api_post("/management/v1/projects", body)
    if resp.status ~= 200 then
      error("zitadel: failed to create project '" .. name .. "' (HTTP " .. resp.status .. "): " .. resp.body)
    end
    local data = json.parse(resp.body)
    log.info("Created project '" .. name .. "' (id=" .. tostring(data.id) .. ")")
    return data
  end

  function c.projects:ensure(name, opts_proj)
    local existing = c.projects:find(name)
    if existing then
      log.info("Project '" .. name .. "' already exists (id=" .. tostring(existing.id) .. ")")
      return existing
    end
    return c.projects:create(name, opts_proj)
  end

  -- ===== OIDC Apps =====

  c.apps = {}

  function c.apps:find(project_id, name)
    local body = {
      query = { limit = 100 },
      queries = { { nameQuery = { name = name, method = "TEXT_QUERY_METHOD_EQUALS" } } },
    }
    local resp = api_post("/management/v1/projects/" .. project_id .. "/apps/_search", body)
    if resp.status ~= 200 then
      -- Fallback: try without query filter (older Zitadel versions)
      resp = api_post("/management/v1/projects/" .. project_id .. "/apps/_search", { query = { limit = 100 } })
      if resp.status ~= 200 then return nil end
    end
    local data = json.parse(resp.body)
    if data.result then
      for _, a in ipairs(data.result) do
        if a.name == name then return a end
      end
    end
    return nil
  end

  function c.apps:create_oidc(project_id, opts_app)
    local redirect_uri = "https://" .. opts_app.subdomain .. "." .. domain .. opts_app.callbackPath
    local logout_uri = "https://" .. opts_app.subdomain .. "." .. domain .. "/"
    local body = {
      name = opts_app.name,
      redirectUris = opts_app.redirectUris or { redirect_uri },
      postLogoutRedirectUris = opts_app.postLogoutRedirectUris or { logout_uri },
      responseTypes = opts_app.responseTypes or { "OIDC_RESPONSE_TYPE_CODE" },
      grantTypes = opts_app.grantTypes or { "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN" },
      appType = opts_app.appType or "OIDC_APP_TYPE_WEB",
      authMethodType = opts_app.authMethodType or "OIDC_AUTH_METHOD_TYPE_BASIC",
      accessTokenType = opts_app.accessTokenType or "OIDC_TOKEN_TYPE_BEARER",
      accessTokenRoleAssertion = opts_app.accessTokenRoleAssertion ~= false,
      idTokenRoleAssertion = opts_app.idTokenRoleAssertion ~= false,
      idTokenUserinfoAssertion = opts_app.idTokenUserinfoAssertion ~= false,
      devMode = opts_app.devMode or false,
      clockSkew = opts_app.clockSkew or "0s",
    }
    local resp = api_post("/management/v1/projects/" .. project_id .. "/apps/oidc", body)
    if resp.status == 409 then
      log.info("OIDC app '" .. opts_app.name .. "' already exists (409), looking up...")
      local existing = c.apps:find(project_id, opts_app.name)
      if existing then return existing end
      log.warn("OIDC app '" .. opts_app.name .. "' exists (409) but search did not find it, returning stub")
      return { id = "existing", name = opts_app.name }
    end
    if resp.status ~= 200 then
      error("zitadel: failed to create OIDC app '" .. opts_app.name .. "' (HTTP " .. resp.status .. "): " .. resp.body)
    end
    local data = json.parse(resp.body)
    log.info("Created OIDC app '" .. opts_app.name .. "' (clientId=" .. tostring(data.clientId) .. ")")
    return data
  end

  function c.apps:ensure_oidc(project_id, opts_app)
    local existing = c.apps:find(project_id, opts_app.name)
    if existing then
      log.info("OIDC app '" .. opts_app.name .. "' already exists (id=" .. tostring(existing.id) .. ")")
      return existing
    end
    return c.apps:create_oidc(project_id, opts_app)
  end

  -- ===== IdPs =====

  c.idps = {}

  function c.idps:find(name)
    local resp = api_post("/admin/v1/idps/templates/_search", {
      queries = { { idpNameQuery = { name = name, method = "TEXT_QUERY_METHOD_EQUALS" } } },
    })
    if resp.status ~= 200 then return nil end
    local data = json.parse(resp.body)
    if data.result and #data.result > 0 then
      return data.result[1]
    end
    return nil
  end

  function c.idps:ensure_google(opts_idp)
    local existing = c.idps:find("Google")
    if existing then
      log.info("Google IdP already exists (id=" .. existing.id .. ")")
      return existing.id
    end
    local body = {
      name = "Google",
      clientId = opts_idp.clientId,
      clientSecret = opts_idp.clientSecret,
      scopes = opts_idp.scopes or { "openid", "email", "profile" },
      providerOptions = opts_idp.providerOptions or {
        isLinkingAllowed = true,
        isCreationAllowed = true,
        isAutoCreation = true,
        isAutoUpdate = true,
      },
    }
    local resp = api_post("/admin/v1/idps/google", body)
    if resp.status ~= 200 then
      log.warn("zitadel: failed to create Google IdP (HTTP " .. resp.status .. ")")
      return nil
    end
    local data = json.parse(resp.body)
    local idp_id = data.idp_id or data.id
    log.info("Created Google IdP (id=" .. tostring(idp_id) .. ")")
    return idp_id
  end

  function c.idps:ensure_oidc(opts_idp)
    local name = opts_idp.name
    assert.not_nil(name, "zitadel: ensure_oidc_idp requires name")
    local existing = c.idps:find(name)
    local provider_options = opts_idp.providerOptions or {
      isLinkingAllowed = true,
      isCreationAllowed = true,
      isAutoCreation = true,
      isAutoUpdate = true,
      autoLinking = opts_idp.autoLinking or "AUTO_LINKING_OPTION_EMAIL",
    }
    local body = {
      name = name,
      clientId = opts_idp.clientId,
      clientSecret = opts_idp.clientSecret,
      issuer = opts_idp.issuer,
      scopes = opts_idp.scopes or { "openid", "email", "profile" },
      isIdTokenMapping = opts_idp.isIdTokenMapping ~= false,
      providerOptions = provider_options,
    }
    if existing then
      log.info(name .. " IdP already exists (id=" .. existing.id .. "), updating...")
      local resp = api_put("/admin/v1/idps/generic_oidc/" .. existing.id, body)
      if resp.status == 200 then
        log.info(name .. " IdP updated")
      else
        log.warn("zitadel: failed to update " .. name .. " IdP (HTTP " .. resp.status .. ")")
      end
      return existing.id
    end
    local resp = api_post("/admin/v1/idps/generic_oidc", body)
    if resp.status ~= 200 then
      log.warn("zitadel: failed to create " .. name .. " IdP (HTTP " .. resp.status .. "): " .. resp.body)
      return nil
    end
    local data = json.parse(resp.body)
    local idp_id = data.id
    log.info("Created " .. name .. " IdP (id=" .. tostring(idp_id) .. ")")
    return idp_id
  end

  function c.idps:add_to_login_policy(idp_id)
    local resp = api_post("/admin/v1/policies/login/idps", {
      idpId = idp_id,
      ownerType = "IDPOWNERTYPE_SYSTEM",
    })
    if resp.status == 200 then
      log.info("IdP " .. idp_id .. " added to login policy")
      return true
    elseif resp.status == 409 then
      log.info("IdP " .. idp_id .. " already in login policy")
      return true
    end
    log.warn("zitadel: failed to add IdP to login policy (HTTP " .. resp.status .. ")")
    return false
  end

  -- ===== Users =====

  c.users = {}

  function c.users:search(query)
    local resp = api_post("/management/v1/users/_search", query)
    if resp.status ~= 200 then
      log.warn("zitadel: user search failed (HTTP " .. resp.status .. ")")
      return {}
    end
    local data = json.parse(resp.body)
    return data.result or {}
  end

  function c.users:update_email(user_id, email)
    local resp = api_put("/management/v1/users/" .. user_id .. "/email", {
      email = email,
      isEmailVerified = true,
    })
    if resp.status == 200 then
      log.info("Updated user " .. user_id .. " email to " .. email)
      return true
    end
    log.warn("zitadel: failed to update user email (HTTP " .. resp.status .. ")")
    return false
  end

  -- ===== Login Policy =====

  c.login_policy = {}

  function c.login_policy:get()
    local resp = api_get("/admin/v1/policies/login")
    if resp.status ~= 200 then return nil end
    local data = json.parse(resp.body)
    return data.policy
  end

  function c.login_policy:update(policy)
    local resp = api_put("/admin/v1/policies/login", policy)
    if resp.status == 200 then
      log.info("Login policy updated")
      return true
    end
    log.warn("zitadel: failed to update login policy (HTTP " .. resp.status .. "): " .. resp.body)
    return false
  end

  function c.login_policy:disable_password()
    local policy = c.login_policy:get()
    if not policy then
      log.warn("zitadel: could not read login policy")
      return false
    end
    if not policy.allowUsernamePassword then
      log.info("Password login already disabled")
      return true
    end
    return c.login_policy:update({
      allowUsernamePassword = false,
      allowExternalIdp = true,
      allowRegister = policy.allowRegister or false,
      forceMfa = policy.forceMfa or false,
      passwordlessType = policy.passwordlessType or "PASSWORDLESS_TYPE_NOT_ALLOWED",
      hidePasswordReset = true,
      passwordCheckLifetime = policy.passwordCheckLifetime,
      externalLoginCheckLifetime = policy.externalLoginCheckLifetime,
      mfaInitSkipLifetime = policy.mfaInitSkipLifetime,
      secondFactorCheckLifetime = policy.secondFactorCheckLifetime,
      multiFactorCheckLifetime = policy.multiFactorCheckLifetime,
    })
  end

  return c
end

return M