From 1a05a8199d10bb6321e0ff89c1a5451c4ac95961 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 18 Mar 2025 18:20:07 +0200 Subject: [PATCH] var improvements for lanes and accounts/ orgs support --- app/src/components/accounts/login.svelte | 33 ++- app/src/make-pouch.js | 85 +++++-- app/src/service-worker/handlers/ipfs.js | 2 + app/src/service-worker/worker.js | 21 +- cli/cloudflare.js | 47 +++- cli/couch.js | 23 +- cli/workerd.js | 9 +- edge/entry-esm.js | 2 + edge/entry.js | 2 + edge/handlers/_couch/index.js | 78 +++--- edge/handlers/_ipfs.js | 6 +- edge/handlers/_proxy.js | 2 +- edge/handlers/_session.js | 289 ++++++++++++++++++----- edge/lib/jwt.js | 107 ++++++--- edge/lib/req.js | 21 +- jsconfig.json | 27 +++ npm-dist/worker.js | 4 +- 17 files changed, 570 insertions(+), 188 deletions(-) create mode 100644 jsconfig.json diff --git a/app/src/components/accounts/login.svelte b/app/src/components/accounts/login.svelte index 690f2f6..2db1a96 100644 --- a/app/src/components/accounts/login.svelte +++ b/app/src/components/accounts/login.svelte @@ -9,12 +9,14 @@ const askEmail = location.hostname.endsWith('localhost') let isIncognito onMount(async () => { - if (userData) { + if (userData || !askEmail) { + // we dont ask for session name or org in the current operation mode, but if we ask for email anyways we can use this for testing login() + return // document.loginForm.submit() } if ('storage' in navigator && 'estimate' in navigator.storage) { - const {usage, quota} = await navigator.storage.estimate?.() + const { usage, quota } = await navigator.storage.estimate?.() if(quota < 1200000000){ isIncognito = true @@ -22,8 +24,8 @@ } }) - let sideWidth = '440' - let contentMin + // let sideWidth = '440' + // let contentMin let saveToDevice = true @@ -44,9 +46,19 @@ function login () { const loginData = { email } + + const sParams = new URLSearchParams(location.search) + + // if org is passed from parent component use it, otherwise check and use the url params if (org) { loginData.org = org + } else { + const orgName = sParams.get('orgName') + if (orgName) { + loginData.org = orgName + } } + if (isIncognito) { loginData.isIncognito = isIncognito } @@ -56,11 +68,22 @@ if (sessionName) { loginData.sessionName = sessionName } - const cont = (new URLSearchParams(location.search)).get('continue') + + const cont = sParams.get('continue') if (cont) { loginData.continue = cont } + const projectName = sParams.get('projectName') + if (projectName) { + loginData.projectName = projectName + } + + const startDemoProject = sParams.get('startDemoProject') + if (startDemoProject) { + loginData.startDemoProject = startDemoProject + } + let searchParams = new URLSearchParams(loginData) let loginUrl = `/_api/_session?login&${searchParams.toString()}` location.href = loginUrl diff --git a/app/src/make-pouch.js b/app/src/make-pouch.js index 26a39ec..a3d92e0 100644 --- a/app/src/make-pouch.js +++ b/app/src/make-pouch.js @@ -14,6 +14,7 @@ async function rateRetryFetch (url, opts, awaitable, count = 1) { await activePush } + // @ts-ignore -- fix pouch type error const res = await PouchDB.fetch(url, opts) if (res.status === 429 && count < 4) { @@ -34,9 +35,12 @@ export default async function ({ sessionId, preload }) { + // @ts-ignore -- fix pouch type error PouchDB.prefix = '_ayu_' // PouchDB.plugin({ bulkDocs: function () { return { self:this, args: arguments }; }}) // PouchDB.plugin({ test: function () { return { self:this, args: arguments }; }}) + + // @ts-ignore -- fix pouch type error const pouch = new PouchDB(clientDbName, { revs_limit: 200, auto_compaction: true, deterministic_revs: true }) let couch let sync @@ -63,10 +67,18 @@ export default async function ({ return acc }, {}) } + + if (seedDoc.filters) { + seedDoc.filters = Object.entries(seedDoc.filters).reduce((acc, [name, filterFun]) => { + acc[name] = filterFun?.toString() + return acc + }, {}) + } return seedDoc }) || []) ]).catch(() => {}) if (hasCouch) { + // @ts-ignore -- check if pouch type error or type error here? couch = new PouchDB(`${location.origin}/_api/_couch/${serverDbName}`, { fetch: async (url, opts) => { opts.redirect = 'error' @@ -96,7 +108,7 @@ export default async function ({ if (idSuffix) { const checkpointDocId = '_local/' + decodeURIComponent(idSuffix) // console.log(opts.method, {checkpointDocId, checkpointCache}) - if (checkpointDocId === sync.pull.replicationId || checkpointDocId === sync.push.replicationId) { + if (checkpointDocId === sync?.pull?.replicationId || checkpointDocId === sync?.push?.replicationId) { checkpointReqKey = checkpointDocId if (opts.method === 'GET' || !opts.method) { @@ -114,6 +126,7 @@ export default async function ({ return rateRetryFetch(url, opts, awaitable).then(async res => { if (res.status === 401 || res.redirected || res.type === 'opaqueredirect') { + // @ts-ignore -- add type setup for global session object or better move to better object handling and encapsulation self.session?.refresh() } @@ -194,27 +207,45 @@ export default async function ({ }) } - return includeDocs ? docs : true + return docs } const [ sessionDoc ] = await pullDocs([sessionId], { includeDocs: true }) // await couch.get(sessionId) - if (!sessionDoc.replications) { - if (preload?.length > 0) { + // first db is user db by convention, further dbs are org dbs for org user belongs to + const replicationConf = sessionDoc.dbs.find(db => db.dbName === serverDbName) + + // on first pull, replicate the preloads + if (!replicationConf.pull) { + if (preload?.length > 0 || typeof preload === 'function') { // TODO: prefix support and functions? // TODO: handle updates // Use batch instead console.log('preloading docs to new pouch...', preload) - await PouchDB.replicate(couch, pouch, { doc_ids: preload }) + + if (typeof preload === 'string' || typeof preload === 'function') { + // @ts-ignore -- fix pouch type error + await PouchDB.replicate(couch, pouch, { filter: preload }).catch(err => { + console.error('preload error', err) + throw err + }) + } else { + // @ts-ignore -- fix pouch type error + await PouchDB.replicate(couch, pouch, { doc_ids: preload }).catch(err => { + console.error('preload error', err) + throw err + }) + } } inited = new Promise((resolve) => { initResolver = resolve }) } - if (!sessionDoc.startSeq) { + if (!replicationConf.startSeq) { console.warn('missing session doc start seq, fallback to fullsync', sessionDoc) } // FIXME: batching sync out not working + // @ts-ignore -- fix pouch type error sync = PouchDB.sync(pouch, couch, { live: true, sse: true, @@ -227,7 +258,7 @@ export default async function ({ batch_size: 50, conflicts: true, // TODO pull: { - since: sessionDoc.startSeq, + since: replicationConf.startSeq, filter: (doc, _opts) => { if (doc._conflicts) { console.warn(doc._conflicts) @@ -301,32 +332,36 @@ export default async function ({ init?.() }) + /** + * Internal initialization function that updates replication IDs in the session document. + * This function sets itself to null after execution to ensure it only runs once. + * @type {(()=>Promise)|null} + */ let init = async () => { init = null // console.log('initting changes session doc') if (sync.pull.replicationId && sync.push.replicationId) { let updateReplications = false - if (sessionDoc.replications) { - if (sessionDoc.replications.pull !== sync.pull.replicationId) { - console.error('pull replication id cahnged', sessionDoc, sync.pull) - // TODO: remove old doc - updateReplications = true - } - if (sessionDoc.replications.push !== sync.push.replicationId) { - console.error('push replication id cahnged', sessionDoc, sync.push) - // TODO: remove old doc - updateReplications = true - } + if (replicationConf.pull !== sync.pull.replicationId) { + // console.error('pull replication id changed', sessionDoc, sync.pull) + // TODO: remove old doc + updateReplications = true } - if (!sessionDoc.replications || updateReplications) { - // console.log('writing replications to session...') - sessionDoc.replications = { - pull: sync.pull.replicationId, - push: sync.push.replicationId - } - await pouch.put(sessionDoc) + if (replicationConf.push !== sync.push.replicationId) { + // console.error('push replication id changed', sessionDoc, sync.push) + // TODO: remove old doc + updateReplications = true + } + + if (!replicationConf.pull || updateReplications) { + replicationConf.pull = sync.pull.replicationId + } + + if (!replicationConf.push || updateReplications) { + replicationConf.push = sync.push.replicationId } + await pouch.put(sessionDoc) } } diff --git a/app/src/service-worker/handlers/ipfs.js b/app/src/service-worker/handlers/ipfs.js index 730a189..8ab9173 100644 --- a/app/src/service-worker/handlers/ipfs.js +++ b/app/src/service-worker/handlers/ipfs.js @@ -33,6 +33,8 @@ export default async function ({ url, origUrl, event, ipfsGateway = '/'}) { contentTypeOverride = 'image/png' } else if (path.endsWith('.svg')) { contentTypeOverride = 'image/svg+xml' + } else if (path.endsWith('.html')) { + contentTypeOverride = 'text/html; charset=utf-8' } if (cache.then) { diff --git a/app/src/service-worker/worker.js b/app/src/service-worker/worker.js index e128694..c025228 100644 --- a/app/src/service-worker/worker.js +++ b/app/src/service-worker/worker.js @@ -12,13 +12,13 @@ import defaultPaths from '../schema/default-routes.js' // TODO: support addtional dataSources export default function ({ dbConf = {}, - dataSources, - schema, + dataSources = null, + schema = null, onChange, clientDbSeeds, proxiedDomains, handlers: appHandlers, - debug + debug = false } = {}) { if (dataSources) { console.warn('Additional data sources not implemented yet.') @@ -112,7 +112,12 @@ export default function ({ }) // console.log('making falcor server') - self.session.falcorServer = makeFalcorServer({ dbs: self.session.dbs, schema, session: newSession, debug }) + self.session.falcorServer = makeFalcorServer({ + dbs: self.session.dbs, + schema, + session: newSession, + debug + }) if (newSession.userId && !self.session.loaded) { redirectOtherClients = 'continue' @@ -137,7 +142,7 @@ export default function ({ } } else { let cont = '' - if (url.pathname.length > 1 || url.hash || url.search > 0) { + if (url.pathname.length > 1 || url.hash || url.search.length > 0) { if (query.get('continue')) { cont = `continue=${query.get('continue')}` } else { @@ -145,10 +150,10 @@ export default function ({ } } if (!url.pathname.startsWith('/_ayu/accounts') && !url.pathname.startsWith('/_api/_session?login')) { - const url = `/_ayu/accounts/?${cont}` // `/_api/_session?login${cont}` - console.log('redirecting client', url, newSession, client) + const newPath = url.hostname.endsWith('localhost') ? `/_ayu/accounts/?${cont}` : `/_api/_session?login${cont}` + console.log('redirecting client', newPath, newSession, client) - client.postMessage('navigate:' + url) + client.postMessage('navigate:' + newPath) return waitForNavigation(client) // after safari support: client.navigate().catch(err => console.error(err)) } diff --git a/cli/cloudflare.js b/cli/cloudflare.js index 0d11092..373e394 100644 --- a/cli/cloudflare.js +++ b/cli/cloudflare.js @@ -14,6 +14,11 @@ export async function cloudflareDeploy ({ }) { // TODO: evaluate https://www.pulumi.com/docs/reference/pkg/cloudflare/ // const startTime = Date.now() + + // migrate to all es module worker + // TOO: implement binding selector to select workers a binding should apply to + // TODO: implement default single worker bundling with optional split by selector + // both select custom tags if (!config.__cloudflareToken) { console.warn(' 🛑 missing cloudflare token in secrets.js file at __cloudflareToken') return @@ -288,7 +293,10 @@ export async function cloudflareDeploy ({ const appPrefix = `${appName.replaceAll('.', '__').toLowerCase()}__` const toSetRoutes = {} - const workerCreations = Object.entries(services).map(async ([workerName, {codePath, routes}]) => { + + // Cloudflare bug: workers cannot be created in parallel, so we have to wait for each worker to be created + // const workerCreations = + for (const [workerName, {codePath, routes}] of Object.entries(services)) { // .map(async ([workerName, {codePath, routes}]) => { const cfWorkerName = appPrefix + workerName routes.forEach((route) => { @@ -306,7 +314,7 @@ export async function cloudflareDeploy ({ // } const scriptPath = codePath.replace(atreyuPath, projectPath).replace('/handlers/', '/build/').replace('/index', '') - const scriptData = Deno.readTextFileSync(scriptPath) + const scriptData = await Deno.readTextFile(scriptPath) // TODO: support manual added bindings wihtout removing: { "name":"____managed_externally","type":"inherit"} config['env'] = env @@ -359,6 +367,24 @@ export async function cloudflareDeploy ({ }) .filter(binding => binding.text || binding.namespace_id) + // TODO: make this general and handle things like no ipfs worker + bindings.push({ + "environment": env, + "name": "ipfsHandler", + "service": `${appPrefix}_ipfs`, + "type": "service" + }) + + if (workerName !== 'setup') { + // FIXME: this is ugly and only works for apps with setup handler + bindings.push({ + "environment": env, + "name": "_setup", + "service": `${appPrefix}setup`, + "type": "service" + }) + } + const bindingsData = JSON.stringify({ body_part: 'script', bindings @@ -392,10 +418,19 @@ ${scriptData} } else { console.log(' ❌ failed creating worker: ' + cfWorkerName, res) } - }) - console.log(` starting deployment of ${workerCreations.length} workers...`) - await Promise.all(workerCreations) + await new Promise(resolve => { + setTimeout(resolve, 200) + }) + } + //) + + // console.log(` starting deployment of ${workerCreations.length} workers...`) + // await Promise.all(workerCreations) + // let i = 0 + // for await (const promise of workerCreations) { + // console.log(await promise, i++) + // } const pathDeletions = [] curRoutes.forEach(route => { @@ -433,7 +468,6 @@ ${scriptData} .catch(err => console.error(err)) })) - // TODO: support worker domains // /workers/domains/records // Request Method: PUT @@ -457,6 +491,7 @@ ${scriptData} body: JSON.stringify(newDns) }) })) + // TODO: dns and worker deletions // TODO: curSubdomains // TODO: pattern = "*${domain}/cdn-cgi/access/logout", enabled = false ? diff --git a/cli/couch.js b/cli/couch.js index 6ce0c32..7452153 100644 --- a/cli/couch.js +++ b/cli/couch.js @@ -28,7 +28,7 @@ export async function couchUpdt ({ 'Content-Type': 'application/json' } - const dbName = 'ayu_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) + const dbName = 'ayu_app_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) const _id = `system:ayu_settings` @@ -85,7 +85,7 @@ export async function couchUpdt ({ } let dbSeeds = [] - // TODO: handle updates + // TODO: handle updates + migrations!!! if (createDb) { try { dbSeeds = [...dbDefaultSeeds] @@ -94,7 +94,24 @@ export async function couchUpdt ({ if (appSeeds?.default) { console.log(` seeding ${appSeeds.default.length} application docs to database`) - dbSeeds = dbSeeds.concat(appSeeds.default) + dbSeeds = dbSeeds.concat( + appSeeds.default.map(seedDoc => { + if (seedDoc.views) { + seedDoc.views = Object.entries(seedDoc.views).reduce((acc, [name, { map, reduce, ...rest }]) => { + acc[name] = { map: map?.toString(), reduce: reduce?.toString(), ...rest } + return acc + }, {}) + } + + if (seedDoc.filters) { + seedDoc.filters = Object.entries(seedDoc.filters).reduce((acc, [name, filterFun]) => { + acc[name] = filterFun?.toString() + return acc + }, {}) + } + return seedDoc + }) + ) } // const currentVersions = await (await fetch(`${couchHost}/${dbName}/_bulk_docs`, { body: , headers })).json() // console.log(currentVersions) diff --git a/cli/workerd.js b/cli/workerd.js index 7c97e0a..2a5df1e 100644 --- a/cli/workerd.js +++ b/cli/workerd.js @@ -35,7 +35,7 @@ export function workerdSetup ({ config.kv_namespaces = ['ipfs'] } - const bindings = Object.entries(config).flatMap(([key, value]) => { + let bindings = Object.entries(config).flatMap(([key, value]) => { if (['appPath', 'defaultEnv', 'repo'].includes(key)) { return [] } @@ -94,6 +94,13 @@ export function workerdSetup ({ }) .join(',\n ') + `,\n (name = "ipfsHandler", service = "${appPrefix}___ipfs")` + // FIXME: this is ugly + if (Object.values(services).find(({routes}) => { + return routes[0] === '/_api/_setup' + })) { + bindings += `,\n (name = "_setup", service = "${appPrefix}__setup")` + } + const serviceDefs = Object.entries(services).map(([workerName, { codePath, routes }]) => { const cfWorkerName = appPrefix + '__' + workerName diff --git a/edge/entry-esm.js b/edge/entry-esm.js index 693731c..8afdbbf 100644 --- a/edge/entry-esm.js +++ b/edge/entry-esm.js @@ -23,6 +23,8 @@ const stats = { let toWait = [] +// console.log('worker', stats.workerId, handler, 'started') + export default { async fetch (request, env, context) { const { diff --git a/edge/entry.js b/edge/entry.js index 69474d1..fbb6883 100644 --- a/edge/entry.js +++ b/edge/entry.js @@ -38,6 +38,8 @@ const stats = { app } +// console.log('worker', stats.workerId, worker, 'started') + addEventListener('fetch', event => { const fetchStart = Date.now() diff --git a/edge/handlers/_couch/index.js b/edge/handlers/_couch/index.js index 1d61d0a..7597696 100644 --- a/edge/handlers/_couch/index.js +++ b/edge/handlers/_couch/index.js @@ -1,15 +1,20 @@ +import { decode } from '../../lib/jwt' // import { fetchStream } from '../../lib/http.js' -// import { handler as sessionHandler } from './_session.js' - // TODO: support non cloudant couchdb auth: // import { authHeaders } from './helpers.js' // await authHeaders({ userId }) // req.headers -// FIXME: unused text json and formdata will lead to unneeded parsing and cloning!!!! +const local = (typeof self !== 'undefined' && !!self.Deno) || typeof workerd !== 'undefined' + +function getCookie (name, cookieString = '') { + const v = cookieString.match('(^|;) ?' + name + '=([^;]*)(;|$)') + return v ? v[2] : null +} +// FIXME: unused text json and formdata will lead to unneeded parsing and cloning!!!! // FIXME: this only works for global trusted org dbs in admin party export default { - fetch (_, { _couchKey, _couchSecret, couchHost }, { req }) { + async fetch (_, { _couchKey, _couchSecret, couchHost }, { req }) { // TODO: use our own fauxton release instead of cloudant one // import handleFauxton from './fauxton' // if (req.url.pathname === '/_utils') { @@ -21,23 +26,39 @@ export default { // if (req.url.pathname.startsWith('/_utils/')) { // return finish(handleFauxton({ req, event })) // } - // if (req.url.pathname.startsWith('/_api/_session')) { - // return sessionHandler({ req, stats, app, parsedBody }) - // } + let tokenResult + if (local && !req.headers['cf-access-jwt-assertion']) { + // no validation for fake null login only in dev mode + const jwt = getCookie('CF_Authorization', req.headers['cookie']) + if (jwt) { + tokenResult = JSON.parse(atob(jwt.split('.')[1])) + } + } else { + const jwt = req.headers['cf-access-jwt-assertion'] + const { valid, payload } = await decode(jwt) + if (!valid) { + return new Response('forbidden', { status: 403 }) + } + tokenResult = payload + } // FIXME: !!!! this is a security issue, we should not allow any path to be proxied const href = couchHost + req.url.pathname.replace('/_api/_couch', '') + req.url.search + console.error('TODO: validate databasess ' + href, tokenResult) + // ["TODO: validate databasess https://40ce802e-d11b-4726-bb6d-9a1a48bb1781-bluemix.cloudantnosqldb.appdomain.cloud/ayu_user_jan__jasdf(64)nasdf(46)asdf/_bulk_get?revs=true&latest=true", {"dev_mock":true,"email":"jasdf@nasdf.asdf","sessionId":"session:aadd4caa-91ab-4080-9f3e-916239adb6b2"}] + + // TODO: caching req.headers.if-none-match W/"3-a04c2ef5d805577085597f72e1c5922a" + // if (req.url.pathname.includes('/_changes')) { - // // const { readable, response } = await fetchStream(href, { - // // method: req.method, - // // headers: { - // // 'Authorization': `Basic ${btoa(_couchKey + ':' + _couchSecret)}`, - // // ...req.headers - // // } - // // }) - // // return new Response(readable, response) - // } else { + // const { readable, response } = await fetchStream(href, { + // method: req.method, + // headers: { + // 'Authorization': `Basic ${btoa(_couchKey + ':' + _couchSecret)}`, + // ...req.headers + // } + // }) + // return new Response(readable, response) delete req.headers.cookie @@ -47,32 +68,9 @@ export default { body: req.raw.body, headers: { ...req.headers, + // headers: await authHeaders({ userId }) // req.headers 'Authorization': `Basic ${btoa(_couchKey + ':' + _couchSecret)}` } }) } } - -// TODO: support cloudflare jwt decoding and cloudflare access features -// import { decode } from '../../lib/jwt' -// if (req.url.pathname === ('/_session') && req.method === 'DELETE') { -// return new Response('redirect', { -// status: 303, -// headers: { 'Location': 'https://' + req.url.hostname + '/cdn-cgi/access/logout' } -// }) -// } -// async function couchProxy ({ url, req, userId }) { -// // TODO: caching req.headers.if-none-match W/"3-a04c2ef5d805577085597f72e1c5922a" -// // let tokenResult = await decode(req.headers['cf-access-jwt-assertion']) -// // if (tokenResult.valid) { -// return fetch(url, { -// method: req.method, -// body: req.raw.body || null, -// // headers: await authHeaders({ userId }) // req.headers -// }) -// // } -// return new Response('Auth Error', { -// status: 500, -// headers: {} -// }) -// } diff --git a/edge/handlers/_ipfs.js b/edge/handlers/_ipfs.js index 714c651..16b208b 100644 --- a/edge/handlers/_ipfs.js +++ b/edge/handlers/_ipfs.js @@ -14,7 +14,7 @@ export default { let disableCache = false const pinName = env === 'prod' ? appName : appName + '_' + env - let kvPrefix = req.url.pathname.startsWith('/_ayu') ? 'ayu:' : pinName + ':' + let kvPrefix = req.url.pathname.startsWith('/_ayu') ? 'atreyu:' : pinName + ':' let ipfsMap let reqHash @@ -63,7 +63,7 @@ export default { // get ipfsmap, get hash const folderHash = ipfsPathParts[0] if (folderHash === app.ayuHash) { - kvPrefix = 'ayu:' + kvPrefix = 'atreyu:' appHash = app.ayuHash path = path.replace(`/ipfs/${folderHash}`, '') } else if (folderHash === app.Hash) { @@ -126,7 +126,7 @@ export default { } if (!path.endsWith('/ipfs-map.json')) { - // TODO: we can also 304 uncahnged ipfs maps + need to poll for hash update in ayu updater + // TODO: we can also 304 unchanged ipfs maps + need to poll for hash update in ayu updater if (ipfsMap[path] === existingHash) { return (new Response(null, { status: 304, statusText: 'Not Modified', 'cache-status': 'browser-cache; hit' })) } else { diff --git a/edge/handlers/_proxy.js b/edge/handlers/_proxy.js index 4a8a6c4..04fd523 100644 --- a/edge/handlers/_proxy.js +++ b/edge/handlers/_proxy.js @@ -19,7 +19,7 @@ function getCookie (name, cookieString = '') { } const { _couchKey, _couchSecret, couchHost, env, appName, resourceFolder } = getEnv(['_couchKey', '_couchSecret', 'couchHost', 'env', 'appName', 'resourceFolder']) -const dbName = 'ayu_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) +const dbName = 'ayu_app_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) const kvs = getKvStore('resources') diff --git a/edge/handlers/_session.js b/edge/handlers/_session.js index dd068ea..828e699 100644 --- a/edge/handlers/_session.js +++ b/edge/handlers/_session.js @@ -1,12 +1,16 @@ // import { authHeaders } from '../couchdb/helpers' // import maybeSetupUser from './setup' +// TODO: migrate to custom domain! (wildcard domain supported via route chaning from *.a.test.de to domain worker eg. a.test.de) import { getEnv } from '/_env.js' import doReq from '../lib/req.js' // eslint-disable-next-line no-restricted-imports import { escapeId } from '../../app/src/lib/helpers.js' +import { decode } from '../lib/jwt.js' -// auth_domain +// TODO: CSRF protection for lofi app? +// TODO: auth_domain instead of hard coded const { + _setup, _couchKey, _couchSecret, couchHost, @@ -15,6 +19,7 @@ const { appName, workerd } = getEnv([ + '_setup', '_couchKey', '_couchSecret', 'couchHost', @@ -26,7 +31,9 @@ const { const authHeaders = { 'Authorization': `Basic ${btoa(_couchKey + ':' + _couchSecret)}` } -const local = (typeof self !== 'undefined' && !!self.Deno) || workerd +// NOTE: deno / workerd local dev setup is a fake test login with zero validation! +// @ts-ignore +const local = (typeof self !== 'undefined' && !!self.Deno) || typeof workerd !== 'undefined' // function setCookie (name, value, days) { // let d = new Date @@ -35,7 +42,7 @@ const local = (typeof self !== 'undefined' && !!self.Deno) || workerd // } // function deleteCookie (name) { setCookie(name, '', -1) } -// TODO: logout detection: +// TODO: better logout detection: // In order to receive a 401 for an expired session, add the following header to all AJAX requests: // X-Requested-With: XMLHttpRequest @@ -44,40 +51,117 @@ function getCookie (name, cookieString = '') { return v ? v[2] : null } + +// TODO: move cookie back to /_api/ ? // TODO: create database if not existing export default async function ({ req, stats, app }) { + let jwtPayload = {} let jwt if (local && !req.headers['cf-access-jwt-assertion']) { + // no validation for fake null login only in dev mode jwt = getCookie('CF_Authorization', req.headers['cookie']) + if (jwt) { + jwtPayload = JSON.parse(atob(jwt.split('.')[1])) + } } else { jwt = req.headers['cf-access-jwt-assertion'] - } + const { valid, payload } = await decode(jwt) + if (!valid) { + return new Response('forbidden: token', { status: 403 }) + } - let jwtPayload = {} - if (jwt) { - jwtPayload = JSON.parse(atob(jwt.split('.')[1])) + jwtPayload = payload + + // TODO:what is the org flow? for github etc? + // ${jwtPayload.iss} + // const identity = await doReq(`https://lanespm.cloudflareaccess.com/cdn-cgi/access/get-identity`, { + // headers: { cookie: `CF_Authorization=${jwt}` } + // }) + // github: { + // "id": 1176068, + // "name": "Jan Johannes", + // "email": "jan@ntr.io", + // "idp": { + // "id": "55f83bb2-0d88-4a4d-852f-85b777d13e92", + // "type": "github" + // }, + // "geo": { + // "country": "EE" + // }, + // "user_uuid": "5bfedae6-c211-57ea-81e4-d143787e5b5b", + // "account_id": "302c98e0d4fb9f5157a57ea35aa8ff82", + // "iat": 1740165842, + // "ip": "85.253.100.191", + // "service_token_status": false, + // "orgs": [ + // { + // "id": 3406112, + // "name": "pouchdb" + // }, + // { + // "id": 6655347, + // "name": "neutrinity" + // }, + // { + // "id": 39856753, + // "name": "cloudless-hq" + // } + // ], + // "teams": [ + // { + // "name": "Maintainers", + // "org_id": 3406112 + // }, + // { + // "name": "botbox", + // "org_id": 6655347 + // } + // ] + // } + // idp Data from your identity provider. + // geo The country where the user authenticated from. + // user_uuid The ID of the user. + // devicePosture The device posture attributes. + // account_id The account ID for your organization. + // iat The timestamp indicating when the user logged in. + // ip The IP address of the user. + // service_token_status True if authentication was through a service token instead of an IdP. + // is_gateway True if the user enabled WARP and authenticated to a Zero Trust team. + // gateway_account_id An ID generated by the WARP client when authenticated to a Zero Trust team. + // device_id The ID of the device used for authentication. + // device_sessions A list of all sessions initiated by the user. } + let cont = null + + if (req.query.continue && req.query.continue !== '/' && req.query.continue !== 'null' && req.query.continue !== 'undefined') { + cont = req.query.continue +} + + if (cont && (!cont.startsWith('/') || cont.includes('..') || cont.includes('http'))) { + return new Response('forbidden: continue', { status: 403 }) + } + if (req.url.search.startsWith('?logout')) { // req.method === 'delete' for deletion session doc? const headers = { 'Cache-Control': 'must-revalidate' }; let continueParam = '' - if (req.query.continue && req.query.continue !== '/' && req.query.continue !== 'null' && req.query.continue !== 'undefined') { - continueParam = '?continue=' + encodeURIComponent(req.query.continue) + if (cont) { + continueParam = '?continue=' + encodeURIComponent(cont) } if (jwtPayload.dev_mock) { headers['Location'] = `/_ayu/accounts/${continueParam}` - headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/_api; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly' + headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict; HttpOnly' } else if (jwtPayload.email) { - const base = `/_ayu/accounts/${continueParam}` + const base = `/` // _ayu/accounts/${continueParam}` headers['Location'] = `/cdn-cgi/access/logout?returnTo=${encodeURIComponent(req.url.origin + base)}` - headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/_api; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly' - headers['Set-Cookie'] = 'AYU_SESSION_ID=deleted; Path=/_api; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly' + headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict; HttpOnly' + headers['Set-Cookie'] = 'AYU_SESSION_ID=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict; HttpOnly' } else { - headers['Location'] = `/_ayu/accounts/?login` - // headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/_api; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly' + headers['Location'] = `/` // _ayu/accounts/?login + headers['Set-Cookie'] = 'CF_Authorization=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict; Secure; HttpOnly' } return new Response('', { @@ -86,17 +170,23 @@ export default async function ({ req, stats, app }) { }) } + // FIXME: ? + // if (req.url.pathname === ('/_session') && req.method === 'DELETE') { + // return new Response('redirect', { + // status: 303, + // headers: { 'Location': 'https://' + req.url.hostname + '/cdn-cgi/access/logout' } + // }) + // } + let curSessionId - let curOrg if (local) { curSessionId = jwtPayload.sessionId - curOrg = jwtPayload.org } else { + // FIXME: remove ayu session id? const sessionId = getCookie('AYU_SESSION_ID', req.headers['cookie']) - const sessionIdParts = (sessionId && sessionId.split('__')) || [] - - curSessionId = sessionIdParts[0] - curOrg = sessionIdParts[1] + // const sessionIdParts = (sessionId && sessionId.split('__')) || [] + curSessionId = sessionId // sessionIdParts[0] + // curOrg = sessionIdParts[1] } const userAgent = req.headers['user-agent'] @@ -114,30 +204,35 @@ export default async function ({ req, stats, app }) { const cf = { ...req.raw.cf, tlsClientAuth: undefined, tlsExportedAuthenticator: undefined, tlsCipher: undefined, clientTcpRtt: undefined, edgeRequestKeepAliveStatus: undefined, requestPriority: undefined, clientAcceptEncoding: undefined, tlsVersion: undefined, httpProtocol: undefined } const email = jwtPayload.email || local && req.query.email - const appDbName = email && 'ayu_app_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) - // const orgDbName = org ? 'ayu_org_' + (env === 'prod' ? escapeId(org) : escapeId(env + '__' + org)) : null + const appDbName = 'ayu_app_' + (env === 'prod' ? escapeId(appName) : escapeId(env + '__' + appName)) const userDbName = email && 'ayu_user_' + (env === 'prod' ? escapeId(email) : escapeId(env + '__' + email)) const userSettingsId = email && 'lanes:settings_' + escapeId(email) + // TODO: + const inviteProjectId = req.query.inviteProjectId + const inviteOrgId = req.query.inviteOrgId + if (req.url.search.startsWith('?login')) { if (!jwtPayload.email && !req.query?.email) { - return new Response('forbidden', { status: 403 }) + return new Response('forbidden: no email identity', { status: 403 }) + } + + const orgName = req.query.org + const startDemoProject = req.query.startDemoProject + const projectName = req.query.projectName + + if (startDemoProject && !cont) { + cont = '/projects/00000' } - // TODO: if already logged in, logout or error - // https://.cloudflareaccess.com/cdn-cgi/access/get-identity - // name: Ja Joh, idp: Data from your identity provider, user_uuid: The ID of the user. + // TODO: if already logged in, logout or error? const newSession = { email: local ? req.query.email : jwtPayload.email, useSessionId: local ? req.query.sessionId : curSessionId, userDbName, userSettingsId, - - // TODO: validate org and setup user/org association - org: req.query.org, sessionName: req.query.sessionName, - app, cf, browserName @@ -145,19 +240,16 @@ export default async function ({ req, stats, app }) { let newSessionId if (couchHost) { - newSessionId = await ensureSession(newSession, { appDbName }) + newSessionId = await ensureSession(newSession, { appDbName, setupOptions: { orgName, projectName, inviteProjectId, inviteOrgId } }) } else { newSessionId = 'ephemeral:' + crypto.randomUUID() } if (local) { // NOTE: deno local is a fake test login with zero validation! - const devJwt = 'dev.' + btoa(JSON.stringify({ dev_mock: true, - email: newSession.email, - org: newSession.org, sessionId: newSessionId })) @@ -165,8 +257,8 @@ export default async function ({ req, stats, app }) { status: 302, headers: { 'Cache-Control': 'must-revalidate', - 'Location': req.query.continue || '/' , - 'Set-Cookie': `CF_Authorization=${devJwt}; Path=/_api; Expires=Tue, 19 Jan 2038 04:14:07 GMT; HttpOnly` // Version=1; + 'Location': cont || '/' , + 'Set-Cookie': `CF_Authorization=${devJwt}; Path=/; Expires=Tue, 19 Jan 2038 04:14:07 GMT; SameSite=Strict; HttpOnly` } }) } @@ -176,21 +268,19 @@ export default async function ({ req, stats, app }) { status: 302, headers: { 'Cache-Control': 'must-revalidate', - 'Set-Cookie': `AYU_SESSION_ID=${newSessionId}${newSession.org ? '__' + newSession.org : ''}; Path=/_api; HttpOnly; Secure; Expires=Tue, 19 Jan 2038 04:14:07 GMT`, // Version=1; - 'Location': req.query.continue || '/' + // ${newSession.org ? '__' + newSession.org : ''} + 'Set-Cookie': `AYU_SESSION_ID=${newSessionId}; Path=/; HttpOnly; SameSite=Strict; Secure; Expires=Tue, 19 Jan 2038 04:14:07 GMT`, + 'Location': cont || '/' } }) } - // TODO: delete account and database // const sessions = await req(`${`${dbHost}/user_${userId}`}/_design/ntr/_view/lastSeen_by_userId?reduce=false`) - // const setupRes = await maybeSetupUser({ - // dbUrl, email, userId, sessions return new Response(JSON.stringify({ userId: jwtPayload.email, email: jwtPayload.email, - org: curOrg, + env, appName: app.appName, appHash: folderHash, @@ -214,21 +304,39 @@ export default async function ({ req, stats, app }) { }) } -async function ensureSession ({ userSettingsId, useSessionId, email, org, sessionName, app, cf, browserName, userDbName }) { // , { appDbName } +async function ensureSession ({ + userSettingsId, + useSessionId, + email, + sessionName, + app, + cf, + browserName, + userDbName +}, { + appDbName, + setupOptions: { orgName, projectName, inviteProjectId, inviteOrgId } +}) { let newSessionDoc if (useSessionId) { - const { json: existingSessionDoc } = await doReq(`${couchHost}/${userDbName}/${useSessionId}`, { + const sessionDocRes = await doReq(`${couchHost}/${userDbName}/${useSessionId}`, { headers: authHeaders }) + + console.warn('validate session doc', sessionDocRes) + + const { json: existingSessionDoc } = sessionDocRes if (existingSessionDoc) { // TODO: error if any value changed or doc is unavailable, validation newSessionDoc = existingSessionDoc } } - - if (!newSessionDoc) { - let { json: { error, update_seq } } = await doReq(`${couchHost}/${userDbName}`, { headers: authHeaders }) + + // TODO: setup user/org association + const orgDbs = [] + if (!newSessionDoc?._id) { + let { json: { error, update_seq: userDbUpdateSeq } } = await doReq(`${couchHost}/${userDbName}`, { headers: authHeaders }) // Create new user with session if (error === 'not_found') { @@ -245,7 +353,6 @@ async function ensureSession ({ userSettingsId, useSessionId, email, org, sessio created_at: Date.now(), updated_at: Date.now(), userSettingsId, - // "password": "apple" roles: [], type: "user" }, @@ -267,16 +374,51 @@ async function ensureSession ({ userSettingsId, useSessionId, email, org, sessio body: { admins: { names: [], - roles: [] + roles: ["_admin"] }, members: { - names: [], // lanesUserId + names: [lanesUserId], roles: [] } }, headers: authHeaders }) + // org, projectName + let lanesOrgId + let orgDbName + if (orgName) { + lanesOrgId = Date.now() + Math.floor(Math.random() * 10000) + orgDbName = 'org_' + lanesOrgId + + await doReq(`${couchHost}/${orgDbName}`, { + method: 'PUT', + headers: authHeaders + }) + + await doReq(`${couchHost}/${orgDbName}` + '/_security', { + method: 'PUT', + body: { + admins: { + names: [], + roles: ["_admin"] + }, + members: { + names: [lanesUserId], + roles: [] + } + }, + headers: authHeaders + }) + + orgDbs.push({ + dbName: orgDbName, + startSeq: null, + push: null, + pull: null + }) + } + // TODO use couch username conversion Buffer.from(prefixedHexName.replace('userdb-', ''), 'hex').toString('utf8') // 'userdb-' + Buffer.from(name).toString('hex'); const username = email.split('@')[0] @@ -294,6 +436,7 @@ async function ensureSession ({ userSettingsId, useSessionId, email, org, sessio method: 'POST', body: { docs: [ + // FIXME: move to _setup call { _id: userSettingsId, lanesUserId, // FIXME username is global unique per user @@ -311,26 +454,56 @@ async function ensureSession ({ userSettingsId, useSessionId, email, org, sessio }, headers: authHeaders }) - + + console.log(await doReq(`${couchHost}/_replicate`, { + method: 'POST', + body: {"source": appDbName, "target": userDbName }, + headers: authHeaders + })) + + console.log(orgDbName && await doReq(`${couchHost}/_replicate`, { + method: 'POST', + body: {"source": appDbName, "target": orgDbName }, + headers: authHeaders + })) + // FIXME: setup replication from app db - const retryRes = await doReq(`${couchHost}/${userDbName}`, { headers: authHeaders }) - update_seq = retryRes.json.update_seq + const retryRes = await doReq(`${couchHost}/${userDbName}`, { headers: authHeaders }) + userDbUpdateSeq = retryRes.json.update_seq + + if (_setup?.fetch) { + await _setup.fetch('http://local/_api/_setup', { + method: 'POST', + // FIXME: make generic arg handover possible and move lanes specifics t _setup + body: JSON.stringify({ orgId: lanesOrgId, orgName, projectName, inviteProjectId, inviteOrgId, email }), + headers: { + 'content-type': 'application/json', + 'ayu-token': 'FIXME: asdffasdf asdfasd ' // internal rpc call or internal namespace in routing or internal secret + } + }) + } } newSessionDoc = { - _id: 'system:' + crypto.randomUUID(), + _id: 'session:' + crypto.randomUUID(), sessionName: sessionName, - org: org, email: email, - title: `${email}${org ? ' (' + org + ')' : ''}`, created: Date.now(), cf, browserName, app, loginCount: 0, type: 'session', - startSeq: update_seq + dbs: [ + { + dbName: userDbName, + startSeq: userDbUpdateSeq, + push: null, + pull: null + }, + ...orgDbs + ] // user-agent, saveToDevice } } diff --git a/edge/lib/jwt.js b/edge/lib/jwt.js index 05fd585..ad3e552 100644 --- a/edge/lib/jwt.js +++ b/edge/lib/jwt.js @@ -1,4 +1,5 @@ /* global crypto, TextEncoder, atob, fetch */ +const keys = {} let certs = null const tokenCache = {} @@ -6,42 +7,80 @@ export async function decode (token) { if (tokenCache[token] && tokenCache[token].payload.exp * 1000 > Date.now()) { return tokenCache[token] } - - if (!certs) { - certs = await (await fetch('https://cloudless.cloudflareaccess.com/cdn-cgi/access/certs')).json() + + if (!token || typeof token !== 'string' || !token.includes('.')) { + return { valid: false, payload: null } } - const parts = token.split('.') - const header = JSON.parse(atob(parts[0])) - const payload = JSON.parse(atob(parts[1])) - - const signature = atob(parts[2].replace(/_/g, '/').replace(/-/g, '+')) - const finalSignature = new Uint8Array(Array.from(signature).map(c => c.charCodeAt(0))) - - const encoder = new TextEncoder() - const data = encoder.encode([parts[0], parts[1]].join('.')) - - const key = certs.keys.find(key => key.kid === header.kid) - const importedKey = await crypto.subtle.importKey( - 'jwk', - key, - { - name: 'RSASSA-PKCS1-v1_5', - hash: 'SHA-256' - }, - false, - [ 'verify' ] - ) - - const validToken = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', importedKey, finalSignature, data) - const valid = validToken && payload.exp * 1000 > Date.now() - const ret = { - valid, - payload - } + try { + const parts = token.split('.') + if (parts.length !== 3) { + return { valid: false, error: 'Invalid token structure' } + } + + const header = JSON.parse(atob(parts[0])) + const payload = JSON.parse(atob(parts[1])) + + if (!payload.iss || !payload.aud || !payload.sub || !payload.exp || !payload.iat) { + return { valid: false, error: 'Missing required claims' } + } + + const now = Math.floor(Date.now() / 1000) + if (payload.iat > now + 300) { // 5 min clock skew allowance + return { valid: false, error: 'Invalid iat claim' } + } + if (payload.exp <= now) { + return { valid: false, error: 'Token expired' } + } + if (payload.nbf && payload.nbf > now) { + return { valid: false, error: 'Token not yet valid' } + } + + // Validate issuer + if (payload.iss !== 'https://lanespm.cloudflareaccess.com') { + return { valid: false, error: 'Invalid issuer' } + } + + if (!keys[header.kid]) { + if (!certs) { + certs = await (await fetch('https://lanespm.cloudflareaccess.com/cdn-cgi/access/certs')).json() + } + + const key = certs.keys.find(key => key.kid === header.kid) + if (!key) { + return { valid: false, error: 'Key not found' } + } + keys[header.kid] = await crypto.subtle.importKey( + 'jwk', + key, + { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256' + }, + false, + [ 'verify' ] + ) + } + + const signature = atob(parts[2].replace(/_/g, '/').replace(/-/g, '+')) + const finalSignature = new Uint8Array(Array.from(signature).map(c => c.charCodeAt(0))) + + const encoder = new TextEncoder() + const data = encoder.encode([parts[0], parts[1]].join('.')) + + const validToken = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', keys[header.kid], finalSignature, data) + const valid = validToken + const ret = { + valid, + payload + } - if (valid) { - tokenCache[token] = ret + if (valid) { + tokenCache[token] = ret + } + return ret + } catch (e) { + console.error(e) + return { valid: false, payload: null } } - return ret } diff --git a/edge/lib/req.js b/edge/lib/req.js index 8bf70df..cc3152b 100644 --- a/edge/lib/req.js +++ b/edge/lib/req.js @@ -2,7 +2,6 @@ import { getKvStore } from '/_kvs.js' import { getWait } from './wait.js' import log from './log.js' - const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) // min, max are inclusive const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1) + min) @@ -12,8 +11,26 @@ const sleepRandom = () => { return sleep(ms) } + // TODO: unify with client cache and rename cacheNS to cache, true is default ns, string sets custom ns -export default async function req (url, { method, body, headers: headersArg = {}, params, ttl, cacheKey, cacheNs, raw: rawArg, redirect = 'manual' } = {}) { + +/** + * Makes an HTTP request with optional caching functionality + * + * @param {string} url - The request URL + * @param {Object} options - Request options + * @param {string} [options.method=''] - The HTTP method (defaults to 'GET' or 'POST' based on body presence) + * @param {any} [options.body=null] - The request body data + * @param {Object} [options.headers={}] - Request headers + * @param {Object} [options.params=null] - URL parameters to append + * @param {number} [options.ttl=null] - Cache time-to-live in seconds + * @param {string} [options.cacheKey=null] - Custom cache key + * @param {string} [options.cacheNs=null] - Cache namespace + * @param {boolean} [options.raw=null] - Return raw response + * @param {string} [options.redirect='manual'] - Redirect behavior + * @returns {Promise} Response object with status, headers, and parsed body + */ +export default async function req (url, { method = '', body = null, headers: headersArg = {}, params = null, ttl = null, cacheKey = null, cacheNs = null, raw: rawArg = false, redirect = 'manual' } = {}) { const { waitUntil, event } = getWait() if (!method) { method = body ? 'POST' : 'GET' diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..0c93911 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ES2022", + "checkJs": true, + "strict": true, + "noImplicitAny": false, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "/_ayu/*": ["_ayu/*"], + "/_env.js": ["_env.js"], + "/_edge/*": ["_edge/*"], + }, + }, + + "globals": { + "globalVariableName": true + }, + "exclude": ["node_modules"] +} diff --git a/npm-dist/worker.js b/npm-dist/worker.js index beb5761..5f70f43 100644 --- a/npm-dist/worker.js +++ b/npm-dist/worker.js @@ -9386,11 +9386,11 @@ async function make_pouch_default({ let updateReplications = false; if (sessionDoc.replications) { if (sessionDoc.replications.pull !== sync2.pull.replicationId) { - console.error("pull replication id cahnged", sessionDoc, sync2.pull); + console.error("pull replication id changed", sessionDoc, sync2.pull); updateReplications = true; } if (sessionDoc.replications.push !== sync2.push.replicationId) { - console.error("push replication id cahnged", sessionDoc, sync2.push); + console.error("push replication id changed", sessionDoc, sync2.push); updateReplications = true; } }