gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit51a7bcdf51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre51a7bcdf/testapp/project.hl

18.3 KB

  1. // testapp/project.hl — THE SMALLEST APP THAT USES "login with ident" (ident piece 2,
  2. // ticket #25). It proves the login button flow end to end (ident README "How apps use ident"):
  3. // 1. register this app in ident (/apps): name + the origin this app runs on
  4. // (TESTAPP_URL). Paste the API key and the secret into this app's setup form.
  5. // 2. `/` links the browser to <ident>/login?key=<api key>&return=<this app>/callback
  6. // 3. ident signs the browser in, the user chooses an identity, ident redirects to
  7. // /callback?ident_code=<one-time code>
  8. // 4. /callback (server side, hl:fetch) POSTs { key, secret, code } to
  9. // <ident>/api/exchange and gets the app-specific identity id — nothing else.
  10. // This app keeps its OWN users keyed by that id (CONCEPT.md "In the app").
  11. // Piece 3 (#26): THE IDENTITY SELECTOR on the same page. `/` includes <ident>/selector.js
  12. // and <ident-selector key="<api key>">; choosing an identity fires `ident-login` with a
  13. // one-time code, the page POSTs it to /selector-login, this server exchanges it exactly as
  14. // /callback does, and the page switches to "logged in" WITHOUT A RELOAD and sets the
  15. // selector's `logged-in`. "Log out" (POST /logout) resets the selector.
  16. // Both logins give the browser this app's OWN session cookie (`testapp<port>sid`, cookies
  17. // ignore ports); `/` renders `logged-in` on the selector when that session is logged in —
  18. // the host tells the selector, the selector keeps no state of its own.
  19. // A REAL APP NEVER SHOWS THE IDENTITY ID (CONCEPT.md); this test page does, to prove the flow.
  20. //
  21. // Run: cd testapp && ../bin/hybriel project.hl
  22. // TESTAPP_PORT (8354), TESTAPP_URL (origin the browser reaches this app at,
  23. // default http://127.0.0.1:<port>), IDENT_URL (http://127.0.0.1:8351),
  24. // TESTAPP_STORE (./storage/testapp.json: key, secret, users, sessions).
  25. import WebFramework from 'hl:web'
  26. import { Response } from 'hl:http1'
  27. import { fetch } from 'hl:fetch'
  28. import { env } from 'hl:proc'
  29. import { readFile, writeFile, exists } from 'hl:fs'
  30. import { randomBytes } from 'hl:crypto'
  31. import Styles from './styles.hl'
  32. import About from './components/about.hl'
  33. appTitle = "ident test app"
  34. styles = Styles
  35. port = env('TESTAPP_PORT') != null ? toNumber(env('TESTAPP_PORT')) : 8354
  36. selfUrl = env('TESTAPP_URL') != null ? env('TESTAPP_URL') : 'http://127.0.0.1:' + port
  37. identUrl = env('IDENT_URL') != null ? env('IDENT_URL') : 'http://127.0.0.1:8351'
  38. storeFile = env('TESTAPP_STORE') != null ? env('TESTAPP_STORE') : './storage/testapp.json'
  39. load = () => {
  40. if (!exists(storeFile)) { return { key = '' secret = '' users = {} count = 0 sessions = {} } }
  41. let d = JSON.parse(readFile(storeFile))
  42. if (d.sessions == null) { d.sessions = {} } // a piece-2 store
  43. return d
  44. }
  45. // THIS APP'S OWN SESSION: cookie -> identity id (server side only)
  46. cookieName = 'testapp' + port + 'sid'
  47. sidOf = (req) => {
  48. let h = req.headers['cookie']
  49. if (h == null) { return null }
  50. for (part of h.split(';')) {
  51. let t = part.trim()
  52. if (t.startsWith(cookieName + '=')) { return t.slice(cookieName.length + 1, t.length) }
  53. }
  54. return null
  55. }
  56. userOf = (data, req) => {
  57. let sid = sidOf(req)
  58. if (sid == null || sid == '') { return null }
  59. let identity = data.sessions[sid]
  60. if (identity == null) { return null }
  61. return data.users[identity]
  62. }
  63. // the app's user for an exchanged identity id (made on its first login) + a new session
  64. logIn = (data, identity) => {
  65. let known = data.users[identity]
  66. let state = 'welcome back'
  67. if (known == null) {
  68. data.count = data.count + 1
  69. known = { n = data.count }
  70. data.users[identity] = known
  71. state = 'new user'
  72. }
  73. let sid = randomBytes(16)
  74. data.sessions[sid] = identity
  75. save(data)
  76. return { state = state n = known.n cookie = cookieName + '=' + sid + '; Path=/; HttpOnly; SameSite=Lax' }
  77. }
  78. // POST <ident>/api/exchange { key, secret, code } -> { status, j }
  79. exchangeCode = (data, code) => {
  80. let r = fetch(identUrl + '/api/exchange', { method = 'POST' json = { key = data.key secret = data.secret code = code } timeoutMs = 5000 })
  81. let j = r.json()
  82. return { status = r.status j = j text = j == null ? r.text() : '' }
  83. }
  84. save = (data) => { writeFile(storeFile, JSON.stringify(data)) }
  85. escape = (s) => { return ('' + s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;') }
  86. // a query value, percent-encoded (encodeURIComponent is a global, hybriel#14)
  87. encode = (s) => { return encodeURIComponent('' + s) }
  88. page = (status, body) => {
  89. let html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ident test app</title><style>body{margin:0;padding:1rem;font:16px/1.5 system-ui,sans-serif;color:rgb(195, 200, 205);background:rgb(25, 30, 35)}main{max-width:34rem;margin:0 auto;display:grid;gap:1rem}a{color:#ce9178}h1{margin:0;font-size:1.3rem;color:rgb(245, 250, 255)}p{margin:0}code{overflow-wrap:anywhere;word-break:break-all;color:rgb(245, 250, 255)}form{display:grid;gap:.6rem;padding:1rem;border:1px solid rgb(70, 75, 80);border-radius:.5rem}label{display:grid;gap:.2rem;font-size:.9rem}input{font:inherit;padding:.5rem;color:rgb(245, 250, 255);background:rgb(15, 20, 25);border:1px solid rgb(70, 75, 80);border-radius:.4rem;width:100%;box-sizing:border-box}button,a.button{justify-self:start;display:inline-block;font:inherit;font-weight:600;padding:.6rem 1.1rem;color:rgb(15, 20, 25);background:#ce9178;border:1px solid #ce9178;border-radius:.4rem;cursor:pointer;text-decoration:none}.error{color:#f44747}.ok{color:#4ec9b0}.muted{color:rgb(145, 150, 155);font-size:.9rem}h2{margin:0;font-size:1.05rem;color:rgb(245, 250, 255)}section{display:grid;gap:.6rem;padding:1rem;border:1px solid rgb(70, 75, 80);border-radius:.5rem}button.quiet{color:rgb(195, 200, 205);background:transparent;border-color:rgb(70, 75, 80)}[hidden]{display:none!important}</style></head><body><main><h1>ident test app <span class="muted">' + escape(selfUrl) + '</span></h1>' + body + '<p><a id="home" href="/">start page</a></p></main></body></html>'
  90. return new Response(html, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })
  91. }
  92. redirect = (url) => { return new Response('', { status = 302 headers = { 'Location' = url 'Cache-Control' = 'no-store' } }) }
  93. setupForm = (data) => {
  94. return '<form id="setup" method="post" action="/setup"><p class="muted">Register this app in ident (Apps → Register an app, origin <code>' + escape(selfUrl) + '</code>) and paste its API key and secret here.</p><label>API key<input id="key" name="key" autocomplete="off" value="' + escape(data.key) + '"></label><label>Secret<input id="secret" name="secret" autocomplete="new-password" type="password"></label><button id="savesetup" type="submit">Save</button></form>'
  95. }
  96. // THE HOST PAGE'S SCRIPT: the selector's code -> this server (/selector-login) -> logged
  97. // in without a reload; logout resets the selector. (An hl string has no escapes: the
  98. // JavaScript below uses double quotes only.)
  99. hostScript = '<script>(() => {' +
  100. 'const sel = document.getElementById("selector");' +
  101. 'const state = document.getElementById("loginstate");' +
  102. 'const out = document.getElementById("selresult");' +
  103. 'const logout = document.getElementById("logout");' +
  104. 'sel.addEventListener("ident-login", async (e) => {' +
  105. 'out.className = "muted"; out.textContent = "exchanging the code …";' +
  106. 'const r = await fetch("/selector-login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: e.detail.code }) });' +
  107. 'const j = await r.json().catch(() => ({}));' +
  108. 'if (!r.ok) { out.className = "error"; out.textContent = "exchange failed (" + r.status + "): " + (j.error || ""); return; }' +
  109. 'state.className = "ok"; state.textContent = "Logged in: this app’s user #" + j.n + " (" + j.state + ")";' +
  110. 'out.className = "muted"; out.innerHTML = "";' +
  111. 'out.append("via the selector, without a reload: ", Object.assign(document.createElement("strong"), { id: "userstate", textContent: j.state }), ", identity id ", Object.assign(document.createElement("code"), { id: "identity", textContent: j.identity }));' +
  112. 'logout.hidden = false;' +
  113. 'sel.loggedIn = true;' +
  114. '});' +
  115. 'logout.addEventListener("click", async () => {' +
  116. 'await fetch("/logout", { method: "POST" });' +
  117. 'state.className = ""; state.textContent = "Not logged in.";' +
  118. 'out.textContent = ""; logout.hidden = true;' +
  119. 'sel.reset();' +
  120. '});' +
  121. '})();</script>'
  122. home = (route, req) => {
  123. let data = load()
  124. if (data.key == '') { return page(200, '<p id="state">Not set up yet.</p>' + setupForm(data)) }
  125. let href = identUrl + '/login?key=' + data.key + '&return=' + encode(selfUrl + '/callback')
  126. let user = userOf(data, req)
  127. let account = user != null ? '<p id="loginstate" class="ok">Logged in: this app’s user #' + user.n + '</p>' : '<p id="loginstate">Not logged in.</p>'
  128. let logoutButton = '<button id="logout" type="button" class="quiet"' + (user != null ? '' : ' hidden') + '>Log out</button>'
  129. let selector = '<ident-selector id="selector" key="' + escape(data.key) + '"' + (user != null ? ' logged-in' : '') + '></ident-selector>'
  130. return page(200, '<p id="state">Set up with API key <code id="setupkey">' + escape(data.key) + '</code>. Users so far: <span id="usercount">' + data.count + '</span>.</p>' +
  131. '<section id="account">' + account + logoutButton + '</section>' +
  132. '<section id="selectorbox"><h2>Log in with the identity selector</h2>' + selector + '<p id="selresult"></p></section>' +
  133. '<p><a id="toinvites" href="/invites">Invites</a></p><section id="buttonbox"><h2>… or with the login button</h2><p><a id="login" class="button" href="' + escape(href) + '">Log in with ident</a></p></section>' +
  134. setupForm(data) +
  135. '<script src="' + escape(identUrl) + '/selector.js"></script>' + hostScript)
  136. }
  137. // the key and the secret: plain ASCII (pk_/sk_ + hex), so the form body needs no decoding
  138. okToken = (v, prefix, n) => {
  139. if (v == null || v.length != prefix.length + n || v.slice(0, prefix.length) != prefix) { return false }
  140. let i = prefix.length
  141. while (i < v.length) {
  142. if (!'0123456789abcdef'.includes(v[i])) { return false }
  143. i = i + 1
  144. }
  145. return true
  146. }
  147. setup = (route, req) => {
  148. if (req.method != 'POST') { return redirect('/') }
  149. let f = {}
  150. for (pair of ('' + req.body).split('&')) {
  151. let eq = pair.indexOf('=')
  152. if (eq > 0) { f[pair.slice(0, eq)] = pair.slice(eq + 1, pair.length).trim() }
  153. }
  154. if (!okToken(f.key, 'pk_', 32) || !okToken(f.secret, 'sk_', 48)) { return page(400, '<p id="result" class="error">That is not an ident API key (pk_…) and secret (sk_…).</p>') }
  155. let data = load()
  156. data.key = f.key
  157. data.secret = f.secret
  158. save(data)
  159. return redirect('/')
  160. }
  161. callback = (route, req) => {
  162. let q = req.query != null ? req.query : {}
  163. let code = q.ident_code
  164. if (code == null || code == '') { return page(400, '<p id="result" class="error">no ident_code on the callback</p>') }
  165. let data = load()
  166. let x = exchangeCode(data, code)
  167. let j = x.j
  168. if (x.status != 200 || j == null || j.identity == null) {
  169. return page(400, '<p id="result" class="error">exchange failed (<span id="status">' + x.status + '</span>): <span id="error">' + escape(j != null ? j.error : x.text) + '</span></p>')
  170. }
  171. // THIS APP'S OWN USER, connected to the identity id (server side)
  172. let u = logIn(data, j.identity)
  173. // an invite came along: ask ident which identity accepted it (it must be the one just exchanged)
  174. let inv = ''
  175. if (q.invite != null && q.invite != '') {
  176. let g = identPost('/api/invites/get', data, { id = q.invite })
  177. if (g.status == 200) {
  178. inv = '<p id="invite">Invite <strong id="invitestate">' + escape(g.j.invite.state) + '</strong> for <span id="inviteproject">' + escape(g.j.invite.project) + '</span> as <span id="inviterole">' + escape(g.j.invite.role) + '</span>; accepted by <span id="invitewho">' + (g.j.invite.identities.includes(j.identity) ? 'this identity' : 'someone else') + '</span>.</p>'
  179. } else { inv = '<p id="invite" class="error">invite lookup failed (' + g.status + ')</p>' }
  180. }
  181. let res = page(200, inv + '<p id="result" class="ok">Logged in: <strong id="userstate">' + u.state + '</strong>, this app’s user #<span id="usernumber">' + u.n + '</span>.</p><p class="muted">ident answered only <code id="answer">' + escape(JSON.stringify(j)) + '</code> — the identity id for THIS app (a real app never shows it).</p><p>identity id: <code id="identity">' + escape(j.identity) + '</code></p>')
  182. res.headers['Set-Cookie'] = u.cookie
  183. return res
  184. }
  185. json = (status, value) => { return new Response(JSON.stringify(value), { status = status headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } }) }
  186. // POST /selector-login {"code":"<hex>"} — the selector's one-time code, from this app's own
  187. // page: exchanged server side like /callback; answers { state, n, identity } + the cookie
  188. selectorLogin = (route, req) => {
  189. if (req.method != 'POST') { return json(405, { error = 'POST only' }) }
  190. let b = ('' + req.body).trim()
  191. let pre = '{"code":"'
  192. if (!b.startsWith(pre) || !b.endsWith('"}')) { return json(400, { error = 'the body must be {"code":"…"}' }) }
  193. let code = b.slice(pre.length, b.length - 2)
  194. if (!okToken(code, '', code.length) || code == '') { return json(400, { error = 'the code must be hex' }) }
  195. let data = load()
  196. let x = exchangeCode(data, code)
  197. let j = x.j
  198. if (x.status != 200 || j == null || j.identity == null) { return json(400, { error = 'exchange failed (' + x.status + '): ' + (j != null ? j.error : x.text) }) }
  199. let u = logIn(data, j.identity)
  200. let res = json(200, { state = u.state n = u.n identity = j.identity })
  201. res.headers['Set-Cookie'] = u.cookie
  202. return res
  203. }
  204. // POST /logout — ends this app's session (ident's own session is untouched)
  205. logout = (route, req) => {
  206. if (req.method != 'POST') { return json(405, { error = 'POST only' }) }
  207. let data = load()
  208. let sid = sidOf(req)
  209. if (sid != null && data.sessions[sid] != null) {
  210. let rest = {}
  211. for (k of data.sessions.keys()) { if (k != sid) { rest[k] = data.sessions[k] } }
  212. data.sessions = rest
  213. save(data)
  214. }
  215. let res = json(200, { loggedOut = true })
  216. res.headers['Set-Cookie'] = cookieName + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'
  217. return res
  218. }
  219. // ---- INVITES (ident#22): this app invites people through ident ---------------------------
  220. // GET /invites lists this app's invites (ident: POST /api/invites/list) and has a form that
  221. // creates one (POST /api/invites; with an address ident mails the link, through its own
  222. // mailer). "Share" is a mailto: link (no script). The link's person lands on /callback with
  223. // `ident_code` AND `invite`; /callback then asks ident which identity accepted the invite.
  224. identPost = (path, data, payload) => {
  225. let body = { key = data.key secret = data.secret }
  226. for (k of payload.keys()) { body[k] = payload[k] }
  227. let r = fetch(identUrl + path, { method = 'POST' json = body timeoutMs = 5000 })
  228. let j = r.json()
  229. return { status = r.status j = j text = j == null ? r.text() : '' }
  230. }
  231. formOf = (req) => {
  232. let f = {}
  233. for (pair of ('' + req.body).split('&')) {
  234. let eq = pair.indexOf('=')
  235. if (eq > 0) { f[pair.slice(0, eq)] = pair.slice(eq + 1, pair.length).trim().replaceAll('%40', '@').replaceAll('%2B', '+') }
  236. }
  237. return f
  238. }
  239. invitesPage = (data, top) => {
  240. let x = identPost('/api/invites/list', data, {})
  241. let rows = ''
  242. if (x.status == 200) {
  243. for (v of x.j.invites) {
  244. rows = rows + '<li class="invite"><strong>' + escape(v.project) + '</strong> as ' + escape(v.role) + ' — <span class="state">' + escape(v.state) + '</span>, used ' + v.used + ' of ' + v.uses + (v.identities.length > 0 ? ', identity <code class="who">' + escape(v.identities.join(' ')) + '</code>' : '') + (v.state == 'open' ? '<form method="post" action="/invites/revoke"><input type="hidden" name="id" value="' + escape(v.id) + '"><button class="revoke quiet" type="submit">Revoke</button></form>' : '') + '</li>'
  245. }
  246. } else { rows = '<li class="error">list failed (' + x.status + '): ' + escape(x.j != null ? x.j.error : x.text) + '</li>' }
  247. return page(200, top + '<section><h2>Invite someone</h2><form id="inviteform" method="post" action="/invites/create"><label>Project<input id="iproject" name="project" value="demo"></label><label>Role<input id="irole" name="role" value="member"></label><label>Email (optional — ident mails the link)<input id="iemail" name="email" type="email"></label><label>Uses<input id="iuses" name="uses" value="1"></label><label>Days<input id="idays" name="days" value="7"></label><button id="create" type="submit">Create invite</button></form></section><section><h2>Invites</h2><ul id="invites">' + rows + '</ul></section>')
  248. }
  249. invites = (route, req) => {
  250. let data = load()
  251. if (data.key == '') { return redirect('/') }
  252. return invitesPage(data, '')
  253. }
  254. invitesCreate = (route, req) => {
  255. if (req.method != 'POST') { return redirect('/invites') }
  256. let data = load()
  257. let f = formOf(req)
  258. let payload = { project = f.project role = f.role 'return' = selfUrl + '/callback' }
  259. if (f.email != null && f.email != '') { payload.email = f.email }
  260. if (f.uses != null && f.uses != '') { payload.uses = toNumber(f.uses) }
  261. if (f.days != null && f.days != '') { payload.days = toNumber(f.days) }
  262. let x = identPost('/api/invites', data, payload)
  263. if (x.status != 200) { return invitesPage(data, '<p id="result" class="error">create failed (' + x.status + '): ' + escape(x.j != null ? x.j.error : x.text) + '</p>') }
  264. let share = 'mailto:?subject=' + encode('Invitation to ' + x.j.project) + '&body=' + encode(x.j.url)
  265. return invitesPage(data, '<section id="created"><p class="ok">Invite made' + (x.j.mailed ? ' and mailed by ident' : '') + '.</p><input id="link" readonly value="' + escape(x.j.url) + '"><p><a id="share" class="button" href="' + escape(share) + '">Share by mail</a></p></section>')
  266. }
  267. invitesRevoke = (route, req) => {
  268. if (req.method != 'POST') { return redirect('/invites') }
  269. let data = load()
  270. let f = formOf(req)
  271. identPost('/api/invites/revoke', data, { id = f.id })
  272. return redirect('/invites')
  273. }
  274. routes = [
  275. { pattern = "/favicon.ico" direct = "" }
  276. { pattern = "/invites" function = invites }
  277. { pattern = "/invites/create" function = invitesCreate }
  278. { pattern = "/invites/revoke" function = invitesRevoke }
  279. { pattern = "/setup" function = setup }
  280. { pattern = "/callback" function = callback }
  281. { pattern = "/selector-login" function = selectorLogin }
  282. { pattern = "/logout" function = logout }
  283. { pattern = "/about" component = About }
  284. { pattern = "/" function = home }
  285. ]
  286. sessionDir = false
  287. server = new WebFramework(routes = routes, styles = styles, port = port)

Branches

Latest commits

  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre