local M = {}
local function urlencode(s)
return (tostring(s):gsub("([^%w%-%.%_%~])", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
function M.client(opts)
opts = opts or {}
local public_url = opts.public_url and opts.public_url:gsub("/+$", "") or nil
local admin_url = opts.admin_url and opts.admin_url:gsub("/+$", "") or nil
local function require_admin()
if not admin_url then
error("hydra: admin_url not configured")
end
end
local function require_public()
if not public_url then
error("hydra: public_url not configured")
end
end
local function admin_get(path_str)
require_admin()
local resp = http.get(admin_url .. path_str)
if resp.status ~= 200 then
error("hydra: GET " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
local function admin_put(path_str, payload)
require_admin()
local resp = http.put(admin_url .. path_str, payload)
if resp.status ~= 200 and resp.status ~= 201 then
error("hydra: PUT " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
local function admin_post(path_str, payload)
require_admin()
local resp = http.post(admin_url .. path_str, payload)
if resp.status ~= 200 and resp.status ~= 201 then
error("hydra: POST " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
local c = {}
c.clients = {}
function c.clients:list(opts)
opts = opts or {}
local qs = ""
if opts.page_size then qs = "?page_size=" .. opts.page_size end
return admin_get("/admin/clients" .. qs)
end
function c.clients:get(client_id)
return admin_get("/admin/clients/" .. client_id)
end
function c.clients:create(spec)
return admin_post("/admin/clients", spec)
end
function c.clients:update(client_id, spec)
spec.client_id = client_id
return admin_put("/admin/clients/" .. client_id, spec)
end
function c.clients:delete(client_id)
require_admin()
local resp = http.delete(admin_url .. "/admin/clients/" .. client_id)
if resp.status ~= 204 and resp.status ~= 200 then
error("hydra: DELETE client HTTP " .. resp.status .. ": " .. resp.body)
end
end
c.oauth2 = {}
function c.oauth2:authorize_url(client_id, opts)
require_public()
opts = opts or {}
local params = {
"client_id=" .. urlencode(client_id),
"response_type=" .. urlencode(opts.response_type or "code"),
"scope=" .. urlencode(opts.scope or "openid profile email"),
"redirect_uri=" .. urlencode(opts.redirect_uri or ""),
}
if opts.state then
params[#params + 1] = "state=" .. urlencode(opts.state)
end
if opts.nonce then
params[#params + 1] = "nonce=" .. urlencode(opts.nonce)
end
if opts.extra then
for k, v in pairs(opts.extra) do
params[#params + 1] = k .. "=" .. urlencode(v)
end
end
return public_url .. "/oauth2/auth?" .. table.concat(params, "&")
end
function c.oauth2:exchange_code(opts)
require_public()
local body = "grant_type=authorization_code"
.. "&code=" .. urlencode(opts.code)
.. "&redirect_uri=" .. urlencode(opts.redirect_uri)
.. "&client_id=" .. urlencode(opts.client_id)
.. "&client_secret=" .. urlencode(opts.client_secret)
local resp = http.post(public_url .. "/oauth2/token", body, {
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
})
if resp.status ~= 200 then
error("hydra: token exchange HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
function c.oauth2:refresh_token(client_id, client_secret, refresh_token)
require_public()
local body = "grant_type=refresh_token"
.. "&refresh_token=" .. urlencode(refresh_token)
.. "&client_id=" .. urlencode(client_id)
.. "&client_secret=" .. urlencode(client_secret)
local resp = http.post(public_url .. "/oauth2/token", body, {
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
})
if resp.status ~= 200 then
error("hydra: refresh token HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
function c.oauth2:introspect(token)
require_admin()
local resp = http.post(admin_url .. "/admin/oauth2/introspect",
"token=" .. urlencode(token),
{ headers = { ["Content-Type"] = "application/x-www-form-urlencoded" } })
if resp.status ~= 200 then
error("hydra: introspect HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
function c.oauth2:revoke_token(client_id, client_secret, token)
require_public()
local body = "token=" .. urlencode(token)
.. "&client_id=" .. urlencode(client_id)
.. "&client_secret=" .. urlencode(client_secret)
local resp = http.post(public_url .. "/oauth2/revoke", body, {
headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
})
if resp.status ~= 200 then
error("hydra: revoke HTTP " .. resp.status .. ": " .. resp.body)
end
end
c.login = {}
function c.login:get(challenge)
return admin_get("/admin/oauth2/auth/requests/login?login_challenge=" .. urlencode(challenge))
end
function c.login:accept(challenge, subject, opts)
opts = opts or {}
local payload = {
subject = subject,
remember = opts.remember,
remember_for = opts.remember_for,
acr = opts.acr,
amr = opts.amr,
context = opts.context,
}
return admin_put("/admin/oauth2/auth/requests/login/accept?login_challenge=" .. urlencode(challenge), payload)
end
function c.login:reject(challenge, err)
return admin_put("/admin/oauth2/auth/requests/login/reject?login_challenge=" .. urlencode(challenge), err or { error = "access_denied" })
end
c.consent = {}
function c.consent:get(challenge)
return admin_get("/admin/oauth2/auth/requests/consent?consent_challenge=" .. urlencode(challenge))
end
function c.consent:accept(challenge, opts)
opts = opts or {}
local payload = {
grant_scope = opts.grant_scope or { "openid", "profile", "email" },
grant_access_token_audience = opts.grant_access_token_audience or {},
remember = opts.remember,
remember_for = opts.remember_for,
session = opts.session,
}
return admin_put("/admin/oauth2/auth/requests/consent/accept?consent_challenge=" .. urlencode(challenge), payload)
end
function c.consent:reject(challenge, err)
return admin_put("/admin/oauth2/auth/requests/consent/reject?consent_challenge=" .. urlencode(challenge), err or { error = "access_denied" })
end
c.logout = {}
function c.logout:get(challenge)
return admin_get("/admin/oauth2/auth/requests/logout?logout_challenge=" .. urlencode(challenge))
end
function c.logout:accept(challenge)
return admin_put("/admin/oauth2/auth/requests/logout/accept?logout_challenge=" .. urlencode(challenge), {})
end
function c.logout:reject(challenge)
return admin_put("/admin/oauth2/auth/requests/logout/reject?logout_challenge=" .. urlencode(challenge), {})
end
c.discovery = {}
function c.discovery:openid_config()
require_public()
local resp = http.get(public_url .. "/.well-known/openid-configuration")
if resp.status ~= 200 then
error("hydra: well-known HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
function c.discovery:jwks()
require_public()
local resp = http.get(public_url .. "/.well-known/jwks.json")
if resp.status ~= 200 then
error("hydra: jwks HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
return c
end
return M