local M = {}
local function array_eq(a, b)
if not a and not b then return true end
if not a or not b then return false end
if #a ~= #b then return false end
local count = {}
for _, v in ipairs(a) do count[v] = (count[v] or 0) + 1 end
for _, v in ipairs(b) do
if not count[v] or count[v] == 0 then return false end
count[v] = count[v] - 1
end
return true
end
local function field_eq(want, got)
if want == nil and got == nil then return true end
if type(want) ~= type(got) then return false end
if type(want) == "table" then return array_eq(want, got) end
return want == got
end
local function drift_field(payload, got)
for k, v in pairs(payload) do
if not field_eq(v, got[k]) then return k end
end
for k, v in pairs(got) do
if payload[k] == nil and v ~= nil then return k end
end
return nil
end
function M.client(url, api_key)
local base_url = url:gsub("/+$", "")
local function headers()
return {
["Authorization"] = "API-Key " .. api_key,
["Content-Type"] = "application/json",
}
end
local function ok(resp) return resp.status >= 200 and resp.status < 300 end
local function api_call(method, path, body)
local u = base_url .. path
if method == "GET" then return http.get(u, { headers = headers() })
elseif method == "DELETE" then return http.delete(u, { headers = headers() })
elseif method == "POST" then return http.post(u, body, { headers = headers() })
elseif method == "PUT" then return http.put(u, body, { headers = headers() })
end
error("rauthy: unsupported method " .. method)
end
local function require_ok(resp, method, path)
if not ok(resp) then
error(string.format("rauthy %s %s: HTTP %d: %s",
method, path, resp.status, resp.body or ""))
end
return resp
end
local c = {}
c.sys = {}
function c.sys:health()
local resp = api_call("GET", "/health")
return ok(resp)
end
function c.sys:wait_healthy(timeout_secs)
timeout_secs = timeout_secs or 120
local interval = 2
local elapsed = 0
while elapsed < timeout_secs do
local resp = http.get(base_url .. "/health", { headers = headers(), timeout = 3 })
if ok(resp) then return true end
sleep(interval)
elapsed = elapsed + interval
end
error("rauthy: never became reachable at " .. base_url .. "/health")
end
c.discovery = {}
function c.discovery:config()
local resp = require_ok(
http.get(base_url .. "/.well-known/openid-configuration", { headers = {} }),
"GET", "/.well-known/openid-configuration"
)
return json.parse(resp.body)
end
function c.discovery:jwks()
local cfg = c.discovery:config()
if not cfg.jwks_uri then
error("rauthy.discovery: response missing jwks_uri")
end
local resp = require_ok(http.get(cfg.jwks_uri, { headers = {} }), "GET", cfg.jwks_uri)
return json.parse(resp.body)
end
c.clients = {}
function c.clients:list()
local resp = require_ok(api_call("GET", "/clients"), "GET", "/clients")
return json.parse(resp.body)
end
function c.clients:get(id)
local resp = api_call("GET", "/clients/" .. id)
if resp.status == 404 then return nil end
require_ok(resp, "GET", "/clients/" .. id)
return json.parse(resp.body)
end
function c.clients:create(payload)
local subset = {
id = payload.id,
name = payload.name,
confidential = payload.confidential,
redirect_uris = payload.redirect_uris,
post_logout_redirect_uris = payload.post_logout_redirect_uris,
}
require_ok(api_call("POST", "/clients", subset), "POST", "/clients")
require_ok(api_call("PUT", "/clients/" .. payload.id, payload), "PUT", "/clients/" .. payload.id)
end
function c.clients:put(id, payload)
require_ok(api_call("PUT", "/clients/" .. id, payload), "PUT", "/clients/" .. id)
end
function c.clients:delete(id)
local resp = api_call("DELETE", "/clients/" .. id)
if not (ok(resp) or resp.status == 404) then
error(string.format("rauthy DELETE /clients/%s: HTTP %d: %s",
id, resp.status, resp.body or ""))
end
end
function c.clients:rebuild(payload)
self:delete(payload.id)
self:create(payload)
end
function c.clients:rotate_secret(id)
local resp = require_ok(
api_call("POST", "/clients/" .. id .. "/secret"),
"POST", "/clients/" .. id .. "/secret"
)
local secret = json.parse(resp.body).secret
if not secret or secret == "" then
error("rauthy: empty secret returned for " .. id)
end
return secret
end
function c.clients:reconcile(payload)
local id = payload.id or error("rauthy.clients:reconcile: payload.id required")
local got = self:get(id)
if not got then
self:create(payload)
local secret = payload.confidential and self:rotate_secret(id) or nil
return { action = "create", secret = secret }
end
if payload.challenges and #payload.challenges > 0
and not (got.challenges and #got.challenges > 0) then
self:rebuild(payload)
local secret = payload.confidential and self:rotate_secret(id) or nil
return { action = "rebuild", secret = secret, reason = "challenges-drift" }
end
local diff = drift_field(payload, got)
if diff then
self:put(id, payload)
return { action = "put", drift_on = diff }
end
return { action = "noop" }
end
return c
end
M.client_presets = {}
function M.client_presets.openbao(opts)
if not opts or not opts.host then
error("rauthy.client_presets.openbao: opts.host required")
end
local host = opts.host
return {
id = opts.id or "openbao",
name = opts.name or "OpenBao",
confidential = true,
enabled = true,
redirect_uris = {
"https://" .. host .. "/ui/vault/auth/oidc/oidc/callback",
"http://localhost:8250/oidc/callback",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
challenges = { "S256" },
force_mfa = false,
}
end
function M.client_presets.argocd(opts)
if not opts or not opts.host then
error("rauthy.client_presets.argocd: opts.host required")
end
local host = opts.host
return {
id = opts.id or "argocd",
name = opts.name or "ArgoCD",
confidential = false,
enabled = true,
redirect_uris = {
"https://" .. host .. "/auth/callback",
"http://localhost:8085/auth/callback",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "EdDSA",
id_token_alg = "EdDSA",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
challenges = { "S256" },
force_mfa = false,
}
end
function M.client_presets.outline(opts)
if not opts or not opts.host then
error("rauthy.client_presets.outline: opts.host required")
end
local host = opts.host
return {
id = opts.id or "outline",
name = opts.name or "Outline",
confidential = true,
enabled = true,
redirect_uris = {
"https://" .. host .. "/auth/oidc.callback",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile" },
default_scopes = { "openid", "email", "profile" },
force_mfa = false,
}
end
function M.client_presets.seafile(opts)
if not opts or not opts.host then
error("rauthy.client_presets.seafile: opts.host required")
end
local host = opts.host
return {
id = opts.id or "seafile",
name = opts.name or "Seafile",
confidential = true,
enabled = true,
redirect_uris = {
"https://" .. host .. "/oauth/callback/",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
force_mfa = false,
}
end
function M.client_presets.paperless(opts)
if not opts or not opts.host then
error("rauthy.client_presets.paperless: opts.host required")
end
local host = opts.host
return {
id = opts.id or "paperless",
name = opts.name or "Paperless-ngx",
confidential = true,
enabled = true,
redirect_uris = {
"https://" .. host .. "/accounts/oidc/rauthy/login/callback/",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
challenges = { "S256" },
force_mfa = false,
}
end
function M.client_presets.immich(opts)
if not opts or not opts.host then
error("rauthy.client_presets.immich: opts.host required")
end
local host = opts.host
return {
id = opts.id or "immich",
name = opts.name or "Immich",
confidential = true,
enabled = true,
redirect_uris = {
"https://" .. host .. "/auth/login",
"https://" .. host .. "/user-settings",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
challenges = { "S256" },
force_mfa = false,
}
end
function M.client_presets.immich_mobile(opts)
if not opts or not opts.host then
error("rauthy.client_presets.immich_mobile: opts.host required")
end
local host = opts.host
return {
id = opts.id or "immich-mobile",
name = opts.name or "Immich (mobile)",
confidential = false,
enabled = true,
redirect_uris = {
"app.immich:///oauth-callback",
},
post_logout_redirect_uris = { "https://" .. host },
allowed_origins = { "https://" .. host },
flows_enabled = { "authorization_code", "refresh_token" },
access_token_alg = "RS256",
id_token_alg = "RS256",
auth_code_lifetime = 60,
access_token_lifetime = 1800,
scopes = { "openid", "email", "profile", "groups" },
default_scopes = { "openid", "email", "profile", "groups" },
challenges = { "S256" },
force_mfa = false,
}
end
return M