diff --git a/clients/js/.gitignore b/clients/js/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/clients/js/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/clients/js/README.md b/clients/js/README.md new file mode 100644 index 00000000..81f78d86 --- /dev/null +++ b/clients/js/README.md @@ -0,0 +1,108 @@ +# @fileglancer/client + +Browser client for the Fileglancer programmatic API. Lets another web app read and write scientific data files through Fileglancer, as the logged-in user, without implementing its own login. + +## How authentication works + +Fileglancer authenticates with a `SameSite=Lax` session cookie. When your app and Fileglancer are served from the same registrable domain (for example `ai-cryoet.int.janelia.org` and `fileglancer.int.janelia.org`, both under `janelia.org`), the browser automatically attaches that cookie to requests your app makes to Fileglancer. So: + +- Your app does **not** handle any tokens or credentials. +- Your app does **not** need its own login. +- The first time a user needs an authenticated operation, `connect()` opens a small Fileglancer popup that runs the normal login (Okta or simple auth) and closes itself. If the user already has a Fileglancer session, this is instant. + +For this to work, an administrator must add your app's origin to Fileglancer's `api_allowed_origins` setting. Requests from origins not on that list are rejected with `403`. + +## Install + +This package lives in the Fileglancer repo under `clients/js`. Build it with `npm run build` (or `pixi`-managed Node), then depend on it locally, or copy `src/index.ts` into your app. + +## Usage + +```ts +import { FileglancerClient, AuthRequiredError } from '@fileglancer/client'; + +const fg = new FileglancerClient({ + baseUrl: 'https://fileglancer.int.janelia.org' +}); + +// Optional: pre-warm the session at app startup (no popup, no user gesture). +// If the user already has a Fileglancer session, later calls just work. +await fg.connectSilently(); + +// In a "Save" click handler (a user gesture, so the popup isn't blocked): +async function onSaveClick() { + try { + await fg.writeFile('groups_scicompsoft', 'results/out.csv', csvBlob); + } catch (err) { + if (err instanceof AuthRequiredError) { + // No session yet — open the login popup, then retry. + await fg.connect(); + await fg.writeFile('groups_scicompsoft', 'results/out.csv', csvBlob); + } else { + throw err; + } + } +} +``` + +The simplest pattern is to always `connect()` first inside the click handler — it short-circuits (no visible popup) when the user is already authenticated: + +```ts +async function onSaveClick() { + await fg.connect(); // instant if already logged in; popup if not + await fg.writeFile('groups_scicompsoft', 'results/out.csv', csvBlob); +} +``` + +## API + +| Method | Description | +|--------|-------------| +| `getAuthStatus()` | Current auth status; safe when unauthenticated. | +| `getAllowedOrigins()` | Origins the server permits (for diagnostics). | +| `connectSilently(timeoutMs?)` | Establish/verify the session via a hidden iframe. No popup, no gesture. Returns `boolean`. | +| `connect(options?)` | Open the login popup and resolve once authenticated. Call from a user gesture. | +| `getFileSharePaths()` | List available file shares. | +| `listFiles(fsp, subpath?)` | List a directory or get info for one path. | +| `readFile(fsp, subpath)` | Read contents; returns a `Response`. | +| `readFileBlob(fsp, subpath)` | Read contents as a `Blob`. | +| `readFileText(fsp, subpath)` | Read contents as a string. | +| `writeFile(fsp, subpath, data, options?)` | Create/overwrite a file. Parent dir must exist. `options.ifMatch` / `options.ifUnmodifiedSince` add optimistic-concurrency preconditions. | +| `createDirectory(fsp, subpath)` | Create a directory. | +| `createFile(fsp, subpath)` | Create an empty file. | +| `rename(fsp, subpath, newPath)` | Rename/move within a share. | +| `remove(fsp, subpath)` | Delete a file or empty directory. | + +`fsp` is a file-share-path name (from `getFileSharePaths()`); `subpath` is the path within that share. + +### Errors + +All failures throw `FileglancerError` (with a numeric `status`). Two subclasses help you branch: + +- `AuthRequiredError` (401) — no valid session; call `connect()` from a user gesture and retry. +- `ForbiddenError` (403) — your origin isn't allowlisted, or the user lacks permission for that path. +- `ConflictError` (412) — an `ifMatch`/`ifUnmodifiedSince` precondition failed; the file changed since you read it. Re-read and retry. + +Optimistic concurrency example: + +```ts +const res = await fg.readFile(fsp, path); +const etag = res.headers.get('etag'); +const text = await res.text(); +// ...user edits... +try { + await fg.writeFile(fsp, path, edited, { ifMatch: etag }); +} catch (err) { + if (err instanceof ConflictError) { + // someone else saved first — re-read and merge/prompt + } else throw err; +} +``` + +By default (`autoConnect: true`), a `401` triggers one silent reconnect-and-retry before surfacing as `AuthRequiredError`. + +## Security notes + +- The session cookie is `httponly` and never exposed to your app's JavaScript. +- The popup posts its result only to your app's exact origin, and only if that origin is on the server allowlist. +- `SameSite=Lax` is preserved (no `SameSite=None`), so this relies on same-site (shared registrable domain) deployment. Apps on an unrelated domain would need a token-based flow instead. diff --git a/clients/js/package.json b/clients/js/package.json new file mode 100644 index 00000000..0841c6d6 --- /dev/null +++ b/clients/js/package.json @@ -0,0 +1,33 @@ +{ + "name": "@fileglancer/client", + "version": "0.1.0", + "description": "Browser client for the Fileglancer programmatic API — read and write scientific data files as the logged-in user, with a popup login handshake.", + "license": "BSD-3-Clause", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + }, + "keywords": [ + "fileglancer", + "janelia", + "ome-zarr", + "files" + ], + "devDependencies": { + "typescript": "^5.8.0" + } +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts new file mode 100644 index 00000000..904d0889 --- /dev/null +++ b/clients/js/src/index.ts @@ -0,0 +1,436 @@ +/** + * @fileglancer/client — browser client for the Fileglancer programmatic API. + * + * Fileglancer authenticates with a SameSite=Lax session cookie. Because the + * integrating app and Fileglancer live under the same registrable domain + * (e.g. *.janelia.org), the browser attaches that cookie to cross-subdomain + * requests automatically — so every call here uses `credentials: 'include'` + * and no tokens are handled by the app. + * + * When there is no session yet, `connect()` opens a short-lived popup to + * Fileglancer's login flow and resolves once the user is authenticated. Call it + * from a user gesture (e.g. a "Save" click) so the popup is not blocked. + */ + +const CONNECT_MESSAGE_TYPE = 'fileglancer:connected'; +const DEFAULT_POPUP_FEATURES = 'width=520,height=640,menubar=no,toolbar=no'; + +export interface FileglancerClientOptions { + /** Base URL of the Fileglancer server, e.g. "https://fileglancer.int.janelia.org". */ + baseUrl: string; + /** + * On a 401, attempt a silent (hidden-iframe) re-connect and retry the request + * once before failing. Defaults to true. A silent reconnect succeeds only if + * the user still has a valid session; otherwise the call rejects with + * AuthRequiredError and the app should call connect() from a user gesture. + */ + autoConnect?: boolean; + /** Window features string for the login popup. */ + popupFeatures?: string; +} + +export interface AuthStatus { + authenticated: boolean; + username?: string; + email?: string; + auth_method?: 'simple' | 'okta'; +} + +export interface FileSharePath { + name: string; + zone?: string; + group?: string; + storage?: string; + mount_path?: string; + linux_path?: string; + mac_path?: string; + windows_path?: string; +} + +export interface FileInfo { + name: string; + path: string; + size: number; + is_dir: boolean; + last_modified?: number; + permissions?: string; + owner?: string; + group?: string; +} + +export interface ConnectOptions { + /** Milliseconds to wait for the user to complete login before rejecting. */ + timeoutMs?: number; +} + +/** Base error for all Fileglancer API failures. */ +export class FileglancerError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'FileglancerError'; + this.status = status; + } +} + +/** Thrown when the request needs an authenticated session that isn't present. */ +export class AuthRequiredError extends FileglancerError { + constructor(message = 'Authentication required') { + super(message, 401); + this.name = 'AuthRequiredError'; + } +} + +/** Thrown when this app's origin is not on the server's allowlist, or access is denied. */ +export class ForbiddenError extends FileglancerError { + constructor(message = 'Forbidden') { + super(message, 403); + this.name = 'ForbiddenError'; + } +} + +/** Thrown when an If-Match / If-Unmodified-Since precondition fails (file changed). */ +export class ConflictError extends FileglancerError { + constructor(message = 'Precondition failed') { + super(message, 412); + this.name = 'ConflictError'; + } +} + +export type WriteData = Blob | ArrayBuffer | ArrayBufferView | string; + +export class FileglancerClient { + private readonly baseUrl: string; + private readonly baseOrigin: string; + private readonly autoConnect: boolean; + private readonly popupFeatures: string; + + constructor(options: FileglancerClientOptions) { + if (!options?.baseUrl) { + throw new Error('FileglancerClient requires a baseUrl'); + } + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.baseOrigin = new URL(this.baseUrl).origin; + this.autoConnect = options.autoConnect ?? true; + this.popupFeatures = options.popupFeatures ?? DEFAULT_POPUP_FEATURES; + } + + // --- Authentication -------------------------------------------------------- + + /** Return the current authentication status (works cross-origin, unauthenticated-safe). */ + async getAuthStatus(): Promise { + const res = await fetch(`${this.baseUrl}/api/auth/status`, { + credentials: 'include' + }); + if (!res.ok) { + throw await this.toError(res); + } + return (await res.json()) as AuthStatus; + } + + /** Return the origins the server permits to use the API. */ + async getAllowedOrigins(): Promise { + const res = await fetch(`${this.baseUrl}/api/auth/allowed-origins`, { + credentials: 'include' + }); + if (!res.ok) { + throw await this.toError(res); + } + const body = (await res.json()) as { origins: string[] }; + return body.origins ?? []; + } + + /** + * Try to confirm/establish the session without any visible UI, using a hidden + * iframe. Resolves true if the user is authenticated, false otherwise. Safe to + * call at app startup (no user gesture required); never opens a popup. + */ + connectSilently(timeoutMs = 8000): Promise { + return new Promise(resolve => { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.setAttribute('aria-hidden', 'true'); + iframe.src = this.connectUrl(true); + + const done = this.waitForConnect(() => null, timeoutMs); + document.body.appendChild(iframe); + done.then(ok => { + iframe.remove(); + resolve(ok); + }); + }); + } + + /** + * Ensure there is an authenticated session, opening a login popup if needed. + * MUST be called from a user gesture (click/keydown) or the popup may be + * blocked. Resolves with the auth status once connected; rejects on timeout, + * a blocked popup, or if the user closes the popup without logging in. + */ + async connect(options: ConnectOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 120000; + + // Reserve the popup synchronously to keep the user gesture, then try a + // silent check first so an already-logged-in user sees only a brief flash + // (or none, if the silent check wins before the popup paints). + const popup = window.open('', 'fileglancer-connect', this.popupFeatures); + try { + if (await this.connectSilently()) { + popup?.close(); + return await this.getAuthStatus(); + } + if (!popup) { + throw new AuthRequiredError( + 'Login popup was blocked. Call connect() directly from a click handler.' + ); + } + popup.location.href = this.connectUrl(false); + const ok = await this.waitForConnect(() => popup, timeoutMs); + if (!ok) { + throw new AuthRequiredError('Login was not completed.'); + } + return await this.getAuthStatus(); + } finally { + if (popup && !popup.closed) { + popup.close(); + } + } + } + + // --- File operations ------------------------------------------------------- + + /** List all file share paths available to the user. */ + async getFileSharePaths(): Promise { + const res = await this.request('/api/file-share-paths', { method: 'GET' }); + await this.assertOk(res); + const body = (await res.json()) as { paths?: FileSharePath[] }; + return body.paths ?? []; + } + + /** + * List the contents of a directory, or return info for a single file/dir. + * Returns the raw JSON payload from the server. + */ + async listFiles(fsp: string, subpath = ''): Promise { + const res = await this.request( + this.filesUrl('/api/files/', fsp, subpath), + { method: 'GET' } + ); + await this.assertOk(res); + return res.json(); + } + + /** + * Read a file's contents. Returns the raw Response so callers can choose how + * to consume it (`.blob()`, `.text()`, `.arrayBuffer()`, or stream `.body`). + */ + async readFile(fsp: string, subpath: string): Promise { + const res = await this.request( + this.filesUrl('/api/content/', fsp, subpath), + { method: 'GET' } + ); + await this.assertOk(res); + return res; + } + + /** Read a file's contents as a Blob. */ + async readFileBlob(fsp: string, subpath: string): Promise { + return (await this.readFile(fsp, subpath)).blob(); + } + + /** Read a file's contents as text. */ + async readFileText(fsp: string, subpath: string): Promise { + return (await this.readFile(fsp, subpath)).text(); + } + + /** + * Write (create or overwrite) a file's contents as the authenticated user. + * The parent directory must already exist — use createDirectory() first. + * + * Optimistic concurrency: pass `ifMatch` (an ETag from a prior read's + * `response.headers.get('etag')`) or `ifUnmodifiedSince` (a Last-Modified + * value) to fail with a 412 ConflictError if the file changed since. Use + * `'*'` for ifMatch to require the file already exist. + */ + async writeFile( + fsp: string, + subpath: string, + data: WriteData, + options: { ifMatch?: string; ifUnmodifiedSince?: string } = {} + ): Promise<{ bytes_written: number }> { + const headers: Record = {}; + if (options.ifMatch) { + headers['If-Match'] = options.ifMatch; + } + if (options.ifUnmodifiedSince) { + headers['If-Unmodified-Since'] = options.ifUnmodifiedSince; + } + const res = await this.request( + this.filesUrl('/api/content/', fsp, subpath), + { method: 'PUT', body: data as BodyInit, headers } + ); + await this.assertOk(res); + return (await res.json()) as { bytes_written: number }; + } + + /** Create a directory (parents must exist). */ + async createDirectory(fsp: string, subpath: string): Promise { + const res = await this.request(this.filesUrl('/api/files/', fsp, subpath), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'directory' }) + }); + await this.assertOk(res); + } + + /** Create an empty file. */ + async createFile(fsp: string, subpath: string): Promise { + const res = await this.request(this.filesUrl('/api/files/', fsp, subpath), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'file' }) + }); + await this.assertOk(res); + } + + /** Rename/move a file or directory within the same file share. */ + async rename(fsp: string, subpath: string, newPath: string): Promise { + const res = await this.request(this.filesUrl('/api/files/', fsp, subpath), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: newPath }) + }); + await this.assertOk(res); + } + + /** Delete a file or (empty) directory. */ + async remove(fsp: string, subpath: string): Promise { + const res = await this.request(this.filesUrl('/api/files/', fsp, subpath), { + method: 'DELETE' + }); + await this.assertOk(res); + } + + // --- Internals ------------------------------------------------------------- + + private connectUrl(silent: boolean): string { + const url = new URL('/connect-complete', this.baseUrl); + url.searchParams.set('origin', window.location.origin); + if (silent) { + url.searchParams.set('silent', '1'); + } + return url.toString(); + } + + private filesUrl(base: string, fsp: string, subpath: string): string { + const url = new URL(base + encodeURIComponent(fsp), this.baseUrl); + if (subpath) { + url.searchParams.set('subpath', subpath); + } + // Return path+query relative to baseUrl for request() to resolve. + return url.pathname + url.search; + } + + private async request( + path: string, + init: RequestInit, + retry = true + ): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + credentials: 'include' + }); + if (res.status === 401 && retry && this.autoConnect) { + // Session may have lapsed; try to restore it silently, then retry once. + const ok = await this.connectSilently(); + if (ok) { + return this.request(path, init, false); + } + } + return res; + } + + /** + * Wait for a `fileglancer:connected` postMessage from the Fileglancer origin. + * `getWindow` returns the popup to watch for premature closure, or null (for + * the hidden-iframe case where there is nothing to poll). + */ + private waitForConnect( + getWindow: () => Window | null, + timeoutMs: number + ): Promise { + return new Promise(resolve => { + let settled = false; + let poll: ReturnType | undefined; + let timer: ReturnType | undefined; + + const cleanup = () => { + window.removeEventListener('message', onMessage); + if (poll !== undefined) { + clearInterval(poll); + } + if (timer !== undefined) { + clearTimeout(timer); + } + }; + const finish = (value: boolean) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + }; + const onMessage = (event: MessageEvent) => { + if (event.origin !== this.baseOrigin) { + return; + } + const data = event.data as { type?: string; authenticated?: boolean }; + if (!data || data.type !== CONNECT_MESSAGE_TYPE) { + return; + } + finish(Boolean(data.authenticated)); + }; + + window.addEventListener('message', onMessage); + timer = setTimeout(() => finish(false), timeoutMs); + + const watched = getWindow(); + if (watched) { + poll = setInterval(() => { + if (watched.closed) { + finish(false); + } + }, 400); + } + }); + } + + private async assertOk(res: Response): Promise { + if (!res.ok) { + throw await this.toError(res); + } + } + + private async toError(res: Response): Promise { + let detail = res.statusText; + try { + const body = await res.clone().json(); + detail = body?.error ?? body?.detail ?? detail; + } catch { + // non-JSON body; keep statusText + } + if (res.status === 401) { + return new AuthRequiredError(detail || 'Authentication required'); + } + if (res.status === 403) { + return new ForbiddenError(detail || 'Forbidden'); + } + if (res.status === 412) { + return new ConflictError(detail || 'Precondition failed'); + } + return new FileglancerError(detail || `Request failed (${res.status})`, res.status); + } +} + +export default FileglancerClient; diff --git a/clients/js/tsconfig.json b/clients/js/tsconfig.json new file mode 100644 index 00000000..0255135d --- /dev/null +++ b/clients/js/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/docs/ProgrammaticAPI.md b/docs/ProgrammaticAPI.md new file mode 100644 index 00000000..acca565c --- /dev/null +++ b/docs/ProgrammaticAPI.md @@ -0,0 +1,95 @@ +# Programmatic API + +Fileglancer exposes an HTTP API that other web apps can use to read and write files on the user's behalf. A second app (for example a data-analysis tool on another subdomain) can browse shares, read files, and save results through Fileglancer without implementing its own file access or its own login. + +## How it works + +Fileglancer authenticates every request with a `SameSite=Lax` session cookie (`fg_session`), set after the user logs in through Okta or simple auth. `SameSite=Lax` is evaluated against the *site* (the registrable domain, e.g. `janelia.org`), not the full origin — so the browser attaches this cookie to requests sent from any page under the same base domain, across subdomains and ports. Ports are ignored entirely for cookie scope. + +That means an app served from `https://ai-cryoet.int.janelia.org` can call `https://fileglancer.int.janelia.org/api/...` with `credentials: 'include'` and the user's session cookie rides along automatically. The integrating app never handles tokens or credentials, and needs no login of its own. Because the same resolved user drives every existing endpoint (the API runs each file operation as that user through a per-user worker), an authenticated caller gets the full existing API surface for free. + +When there is no session yet, the app opens a short-lived Fileglancer popup that runs the normal login flow and then closes itself. If the user already has a Fileglancer session, this is instant (or invisible, via a hidden-iframe check). + +This design relies on the apps sharing a registrable domain. An app on an unrelated domain would need a token-based flow instead (not currently implemented). + +## Configuration + +Add each integrating app's origin to `api_allowed_origins` in `config.yaml`: + +```yaml +api_allowed_origins: + - https://ai-cryoet.int.janelia.org + - https://nextflow.int.janelia.org:8444 +``` + +List full origins: scheme, host, and port if non-default. The Fileglancer UI's own origin is always allowed and need not be listed. Development subdomains and ports (e.g. `https://nextflow.int.janelia.org:8443` for a Fileglancer dev site paired with `:8444` for a cryoet dev site) are configured the same way, per deployment. + +This list is the cross-site security boundary. On every authenticated endpoint, a request that carries an `Origin` header which is neither same-origin nor on this list is rejected with `403` before the session is consulted. Requests with no `Origin` header (same-origin GETs, server-to-server, curl) are unaffected. This closes a latent exposure: the CORS policy is intentionally wide open (`*`) so anonymous `/files/{sharing_key}` data links work in external viewers like Neuroglancer, which would otherwise let any same-site page ride a logged-in cookie. The allowlist gates the cookie-authenticated surface specifically. + +For local development over plain HTTP, set `session_cookie_secure: false` so the cookie is sent without HTTPS. + +## API endpoints + +All endpoints require the session cookie. The path segment after `/api/files/` or `/api/content/` is a file-share-path name; the path within that share is passed as the `subpath` query parameter. + +| Method & path | Purpose | +|---------------|---------| +| `GET /api/auth/status` | Current auth status (safe when unauthenticated). | +| `GET /api/auth/allowed-origins` | The configured cross-origin allowlist (public). | +| `GET /api/file-share-paths` | List available file shares. | +| `GET /api/files/{fsp}?subpath=...` | List a directory, or get info for one path. | +| `GET /api/content/{fsp}?subpath=...` | Read file contents (supports HTTP Range). | +| `PUT /api/content/{fsp}?subpath=...` | Create or overwrite a file with the request body (streamed). | +| `POST /api/files/{fsp}?subpath=...` | Create an empty file or a directory (`{"type": "file"|"directory"}`). | +| `PATCH /api/files/{fsp}?subpath=...` | Rename/move (`{"path": ...}`) or change permissions (`{"permissions": ...}`). | +| `DELETE /api/files/{fsp}?subpath=...` | Delete a file or empty directory. | + +`PUT /api/content` is the write path used by a "Save" action. It creates the file if absent or replaces its contents if present, streaming the body to disk so large uploads do not buffer in memory. The parent directory must already exist — create it first with `POST /api/files` (`{"type": "directory"}`). The file is created with the user's ownership and permissions. + +### Optimistic concurrency + +To avoid clobbering a concurrent edit, `GET`/`HEAD /api/content` return an `ETag` (a strong validator derived from the file's modification time and size) and a `Last-Modified` header. A `PUT` may then carry a precondition: + +- `If-Match: ""` — write only if the file's current ETag matches. Use `If-Match: *` to require the file already exist. +- `If-Unmodified-Since: ` — write only if the file hasn't changed since that time (1-second granularity — `If-Match` is the precise option). + +If the precondition fails the server returns `412 Precondition Failed` and **leaves the file untouched** (the check runs against the opened file before it is truncated). The typical flow: read the file (keep its `ETag`), and on save `PUT` with `If-Match: `; on `412`, tell the user the file changed and re-read. + +ETag caveat: it's based on mtime + size, so two writes within the filesystem's mtime resolution (coarse on some NFS) that also keep the same size can share an ETag. Good enough for interactive save-conflict detection, not a substitute for locking. + +## The connect (login) flow + +A dedicated bare page, `GET /connect-complete`, drives the popup handshake. The integrating app opens it (in a popup, or a hidden iframe with `?silent=1`) with an `origin` query parameter naming the app's own origin. Once the user is authenticated, the page posts a message back to that origin and, for a popup, closes itself: + +```js +{ type: 'fileglancer:connected', authenticated: true, username: '...' } +``` + +The page validates the requested origin against the server allowlist before posting, and only ever targets that exact origin. If the user is not authenticated, a popup is forwarded through the normal `/login` flow (returning to `/connect-complete` afterward), while a silent iframe reports `authenticated: false` so the caller knows to escalate to a visible popup. + +The popup channel does not carry any secret: the session cookie is `httponly` and is set on the Fileglancer origin, never exposed to the app. Even if a message reached a page that was not allowlisted, that page still could not call the API — the server rejects its origin with `403`. + +## JavaScript client + +A small browser client, `@fileglancer/client`, is provided under `clients/js/`. It wraps the endpoints, sends every request with credentials, and implements the popup/iframe handshake. Typical usage in a "Save" handler: + +```ts +import { FileglancerClient } from '@fileglancer/client'; + +const fg = new FileglancerClient({ baseUrl: 'https://fileglancer.int.janelia.org' }); + +async function onSaveClick() { + await fg.connect(); // instant if already logged in; popup if not + await fg.writeFile('groups_scicompsoft', 'results/out.csv', csvBlob); +} +``` + +See `clients/js/README.md` for the full method list and error handling. + +## Security summary + +- The session cookie is `httponly` and never exposed to the integrating app's JavaScript. +- `SameSite=Lax` is preserved — no `SameSite=None`, so this is not subject to third-party-cookie deprecation. It works because the apps are same-site. +- `api_allowed_origins` is enforced on the server for every authenticated request; it is the authoritative cross-site boundary. +- The connect popup validates and targets only allowlisted origins, and transmits no secret. +- All file operations run as the authenticated user via the existing per-user worker, so filesystem permissions apply unchanged. diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 24b0fa73..24c0102a 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -124,6 +124,28 @@ session_cookie_name: fg_session # session_cookie_secure: true +# +# Programmatic API — cross-origin allowlist +# +# Browser apps on other origins (subdomains/ports under the same base domain) +# that may call the authenticated Fileglancer API using the logged-in user's +# session cookie. List full origins (scheme + host + optional port). The +# Fileglancer UI's own origin is always allowed and need not be listed. +# Requests carrying an Origin header not in this list are rejected with 403 on +# authenticated endpoints — this is the CSRF / cross-site boundary. Empty +# (default) means only same-origin calls work. See docs/ProgrammaticAPI.md. +# +# api_allowed_origins: +# - https://ai-cryoet.int.janelia.org +# - https://nextflow.int.janelia.org:8444 + +# +# Maximum size in bytes accepted by a PUT /api/content upload. Guards the +# streaming write path against an unbounded upload filling the disk. Set to 0 +# to disable the limit. Default is 50 GiB. +# +# max_upload_size_bytes: 53687091200 + # # Environment setup — sourced at startup before any scheduler commands. # Useful when running as a systemd service where login scripts don't run. diff --git a/fileglancer/auth.py b/fileglancer/auth.py index a10d058c..6cf3e101 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -5,6 +5,7 @@ import hashlib from datetime import datetime, timedelta, UTC from typing import Optional +from urllib.parse import urlsplit from authlib.integrations.starlette_client import OAuth from fastapi import HTTPException, Request, Response @@ -105,6 +106,59 @@ def get_session_from_cookie(request: Request, settings: Settings) -> Optional[db return user_session +def _normalize_origin(origin: str) -> str: + """Normalize an origin string to 'scheme://host[:port]' with no trailing slash.""" + return origin.strip().rstrip('/') + + +def is_origin_allowed(request: Request, settings: Settings) -> bool: + """Check whether a request's Origin is allowed to use the session cookie. + + The session cookie is SameSite=Lax, so a browser will attach it to requests + from any same-site page — including other subdomains under the same + registrable domain. Combined with the wildcard CORS policy (kept wide open + for the anonymous /files/ data links), that means any *.janelia.org page + could otherwise ride a logged-in user's cookie. This is the app-layer gate: + + - No Origin header (same-origin GET/HEAD, curl, server-to-server) -> allow. + - Origin whose host:port matches the request Host header (the Fileglancer + UI calling its own API) -> allow. Comparing against Host rather than a + configured self-origin keeps this correct behind TLS-terminating proxies. + - Origin listed in api_allowed_origins -> allow. + - Anything else -> reject. + """ + origin = request.headers.get('origin') + if not origin: + return True + + # Same-origin: the Origin's netloc matches the Host the client addressed. + host = request.headers.get('host', '') + try: + origin_netloc = urlsplit(origin).netloc + except ValueError: + return False + if origin_netloc and origin_netloc == host: + return True + + allowed = {_normalize_origin(o) for o in settings.api_allowed_origins} + return _normalize_origin(origin) in allowed + + +def enforce_request_origin(request: Request, settings: Settings) -> None: + """Reject cross-site requests whose Origin is not allowlisted. + + Raises HTTPException(403) when the Origin is present and not permitted. + Call this before resolving the user on any cookie-authenticated endpoint. + """ + if not is_origin_allowed(request, settings): + origin = request.headers.get('origin') + logger.warning(f"Rejected cross-origin request from disallowed origin: {origin}") + raise HTTPException( + status_code=403, + detail="This origin is not allowed to access the Fileglancer API.", + ) + + def get_current_user(request: Request, settings: Settings) -> str: """ Get the current authenticated user diff --git a/fileglancer/server.py b/fileglancer/server.py index 351a0c58..f429da3d 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -40,7 +40,7 @@ from fileglancer.model import * from fileglancer.settings import get_settings from fileglancer.issues import create_jira_ticket, get_jira_ticket_details, delete_jira_ticket -from fileglancer.utils import format_timestamp, guess_content_type, parse_range_header +from fileglancer.utils import format_timestamp, guess_content_type, parse_range_header, make_etag, parse_http_date_to_epoch from fileglancer.filestore import Filestore, RootCheckError from fileglancer.log import AccessLogMiddleware from fileglancer.worker_pool import WorkerPool, WorkerError, WorkerDead @@ -174,8 +174,14 @@ def get_current_user(request: Request): If OKTA auth is enabled, validates session from cookie If OKTA auth is disabled, falls back to $USER environment variable + + Also enforces the cross-origin allowlist: a request carrying an Origin + header that is neither same-origin nor listed in api_allowed_origins is + rejected with 403 before the session is even consulted. """ - return auth.get_current_user(request, get_settings()) + settings = get_settings() + auth.enforce_request_origin(request, settings) + return auth.get_current_user(request, settings) def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket: @@ -376,8 +382,11 @@ async def _worker_exec(username: str, action: str, **kwargs): except Exception as e: logger.exception(f"Action handler error for {username} action={action}: {e}") raise HTTPException(status_code=500, detail=str(e)) - # Strip the raw fd (not meaningful in-process), keep _file_handle + # Strip the raw fd (not meaningful in-process), keep _file_handle. + # _fd_mode is only a hint for the subprocess fd wrapper; in-process + # the handler already returns a handle opened in the right mode. result.pop("_fd", None) + result.pop("_fd_mode", None) return result def _resolve_proxy_info(sharing_key: str, captured_path: str) -> Tuple[dict | Response, str]: @@ -798,6 +807,19 @@ async def auth_status(request: Request): return {"authenticated": False, "auth_method": auth_method} + @app.get("/api/auth/allowed-origins", + description="List cross-origin app origins permitted to use the API") + async def allowed_origins(): + """Return the configured cross-origin allowlist. + + Public (unauthenticated) so an integrating app's connect popup can + confirm its own origin is permitted before initiating the flow. The + list is not sensitive — it is the same boundary the server enforces on + every authenticated request. + """ + return {"origins": [str(o) for o in settings.api_allowed_origins]} + + @app.get("/api/file-share-paths", response_model=FileSharePathResponse, description="Get all file share paths from the database") async def get_file_share_paths() -> List[FileSharePath]: @@ -1454,6 +1476,8 @@ async def head_file_content(path_name: str, headers['Content-Length'] = str(info["size"]) if info.get("last_modified") is not None: headers['Last-Modified'] = format_timestamp(info["last_modified"]) + if info.get("size") is not None: + headers['ETag'] = make_etag(info["last_modified"], info["size"]) return Response(status_code=200, headers=headers, media_type=content_type) @@ -1482,8 +1506,15 @@ async def get_file_content(request: Request, path_name: str, subpath: Optional[s file_size = result["file_size"] content_type = result["content_type"] + last_modified = result.get("last_modified") file_name = subpath.split('/')[-1] if subpath else '' + # Validators for optimistic-concurrency writes (see PUT below). + validator_headers = {} + if last_modified is not None: + validator_headers['Last-Modified'] = format_timestamp(last_modified) + validator_headers['ETag'] = make_etag(last_modified, file_size) + range_header = request.headers.get('Range') if range_header: @@ -1502,6 +1533,7 @@ async def get_file_content(request: Request, path_name: str, subpath: Optional[s 'Accept-Ranges': 'bytes', 'Content-Length': str(content_length), 'Content-Range': f'bytes {start}-{end}/{file_size}', + **validator_headers, } if content_type == 'application/octet-stream' and file_name: @@ -1519,6 +1551,7 @@ async def get_file_content(request: Request, path_name: str, subpath: Optional[s headers = { 'Accept-Ranges': 'bytes', 'Content-Length': str(file_size), + **validator_headers, } if content_type == 'application/octet-stream' and file_name: @@ -1532,6 +1565,88 @@ async def get_file_content(request: Request, path_name: str, subpath: Optional[s ) + @app.put("/api/content/{path_name:path}") + async def put_file_content(request: Request, path_name: str, + subpath: Optional[str] = Query(''), + username: str = Depends(get_current_user)): + """Write the request body to a file, as the authenticated user. + + Creates the file if it does not exist, or replaces its contents if it + does. The parent directory must already exist. The body is streamed to + disk so large uploads do not buffer in memory. + """ + if subpath: + filestore_name = path_name + else: + filestore_name, _, subpath = path_name.partition('/') + + if not subpath: + raise HTTPException(status_code=400, detail="File path is required") + + # Sanitize the target path the same way file creation does, to prevent + # path traversal outside the file share. + normalized_path = os.path.normpath(subpath) + if normalized_path.startswith('..') or os.path.isabs(normalized_path): + raise HTTPException(status_code=400, detail="Path cannot escape the current directory") + _validate_filename(os.path.basename(normalized_path)) + + # Optimistic concurrency: honor If-Match (ETag) and If-Unmodified-Since. + # A mismatch returns 412 from the worker without altering the file. + if_match = request.headers.get('if-match') + ius_raw = request.headers.get('if-unmodified-since') + if_unmodified_since = parse_http_date_to_epoch(ius_raw) if ius_raw else None + if ius_raw and if_unmodified_since is None: + raise HTTPException(status_code=400, detail="Invalid If-Unmodified-Since header") + + # Cap upload size. Reject up front on a declared Content-Length so we + # never open/truncate the file for an obviously-too-large upload; the + # streaming loop below re-checks for chunked or dishonest clients. + max_size = settings.max_upload_size_bytes + if max_size: + declared = request.headers.get('content-length') + if declared and declared.isdigit() and int(declared) > max_size: + raise HTTPException(status_code=413, + detail=f"Upload exceeds the maximum size of {max_size} bytes") + + result = await _worker_exec(username, "write_file", + fsp_name=filestore_name, subpath=normalized_path, + if_match=if_match, if_unmodified_since=if_unmodified_since) + + if result.get("redirect"): + redirect_url = f"/api/content/{result['fsp_name']}" + if result.get("subpath"): + redirect_url += f"?subpath={result['subpath']}" + # 307 preserves the PUT method and body when the client re-requests. + return RedirectResponse(url=redirect_url, status_code=307) + if "error" in result: + raise HTTPException(status_code=result.get("status_code", 500), detail=result["error"]) + + file_handle = result.get("_file_handle") + if file_handle is None: + raise HTTPException(status_code=500, detail="Failed to open file for writing") + + loop = asyncio.get_event_loop() + bytes_written = 0 + try: + async for chunk in request.stream(): + if not chunk: + continue + if max_size and bytes_written + len(chunk) > max_size: + # ponytail: leaves a partial file on disk (bounded by + # max_size); the early Content-Length check avoids this for + # honest clients. Unlink-on-abort if that ever matters. + raise HTTPException(status_code=413, + detail=f"Upload exceeds the maximum size of {max_size} bytes") + await loop.run_in_executor(None, file_handle.write, chunk) + bytes_written += len(chunk) + finally: + await loop.run_in_executor(None, file_handle.close) + + logger.info(f"User {username} wrote {bytes_written} bytes to {filestore_name}/{normalized_path}") + return JSONResponse(status_code=200, + content={"message": "File written", "bytes_written": bytes_written}) + + @app.get("/api/files/{path_name}") async def get_file_metadata(path_name: str, subpath: Optional[str] = Query(''), limit: Optional[int] = Query(None), diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 68c8ba4f..73a3dfcb 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -98,6 +98,11 @@ class Settings(BaseSettings): # Prevents a full directory scan for the count in very large directories. max_directory_count: int = 10000 + # Maximum size in bytes accepted by a PUT /api/content upload. Guards the + # streaming write path against an unbounded upload filling the disk. 0 + # disables the limit. Default 50 GiB. + max_upload_size_bytes: int = 50 * 1024 ** 3 + # OKTA OAuth/OIDC settings for authentication okta_domain: Optional[str] = None okta_client_id: Optional[str] = None @@ -113,6 +118,16 @@ class Settings(BaseSettings): # Authentication toggle - if False, falls back to $USER environment variable enable_okta_auth: bool = False + # Cross-origin browser apps allowed to call the authenticated API using the + # logged-in user's session cookie. List full origins (scheme + host + optional + # port), e.g. "https://ai-cryoet.int.janelia.org" or + # "https://nextflow.int.janelia.org:8444". Same-origin requests (the + # Fileglancer UI itself) are always allowed and need not be listed. Requests + # that carry an Origin header not matching this list are rejected on + # cookie-authenticated endpoints — this is the CSRF / cross-site boundary for + # the programmatic API. Empty (default) means only same-origin calls work. + api_allowed_origins: List[str] = [] + # CLI mode - enables auto-login endpoint for standalone CLI usage cli_mode: bool = False diff --git a/fileglancer/user_worker.py b/fileglancer/user_worker.py index 5eeae7ea..d128d9c2 100644 --- a/fileglancer/user_worker.py +++ b/fileglancer/user_worker.py @@ -498,6 +498,7 @@ def _action_open_file(request: dict, ctx: WorkerContext, filestore, fsps) -> dic return { "file_size": file_info.size, + "last_modified": file_info.last_modified, "content_type": content_type, "_fd": fd, "_file_handle": file_handle, # kept alive until fd is sent @@ -510,6 +511,83 @@ def _action_open_file(request: dict, ctx: WorkerContext, filestore, fsps) -> dic return {"error": f"Permission denied: {fsp_name}/{subpath}", "status_code": 403} +@action("write_file") +@with_filestore +def _action_write_file(request: dict, ctx: WorkerContext, filestore, fsps) -> dict: + """Open a file for writing (truncating) and return a writable file descriptor. + + Mirrors open_file: the worker opens the file as the user so ownership and + permissions are the user's, then passes the fd back via SCM_RIGHTS. The main + process streams the request body into the fd. The "_fd_mode" key tells the + parent to wrap the received fd for writing rather than reading. + + The parent directory must already exist; create it first via create_dir. + """ + fsp_name = request["fsp_name"] + subpath = request.get("subpath", "") + if_match = request.get("if_match") + if_unmodified_since = request.get("if_unmodified_since") + + from fileglancer.filestore import RootCheckError + from fileglancer.utils import make_etag + + if not subpath: + return {"error": "A file path is required", "status_code": 400} + + def _handle(fh): + return { + "_fd": fh.fileno(), + "_fd_mode": "wb", + "_file_handle": fh, # kept alive until fd is sent + } + + try: + full_path = filestore._check_path_in_root(subpath) + if os.path.isdir(full_path): + return {"error": "Cannot write file content to a directory", "status_code": 400} + + if if_match is not None or if_unmodified_since is not None: + # Optimistic concurrency: validate the precondition against the real + # file before destroying anything. Open write-only WITHOUT create or + # truncate so a failed check leaves the file untouched, then truncate + # only once the check passes (race-tight — the fd is the exact inode + # we validated). + try: + fd = os.open(full_path, os.O_WRONLY) + except FileNotFoundError: + if if_match is not None: + # If-Match (incl. "*") needs an existing version to match. + return {"error": "Precondition failed: file does not exist", "status_code": 412} + # Only If-Unmodified-Since on a missing file → treat as a create. + return _handle(open(full_path, 'wb')) + + st = os.fstat(fd) + current_etag = make_etag(st.st_mtime, st.st_size) + stale = ( + (if_match is not None and if_match != '*' and if_match != current_etag) + # If-Unmodified-Since is second-granularity by HTTP spec. + or (if_unmodified_since is not None and int(st.st_mtime) > int(if_unmodified_since)) + ) + if stale: + os.close(fd) + return {"error": "Precondition failed: file has changed since it was read", + "status_code": 412} + os.ftruncate(fd, 0) + return _handle(os.fdopen(fd, 'wb')) + + # No precondition — create the file as the user if absent, or replace + # existing contents while preserving ownership. + return _handle(open(full_path, 'wb')) + except RootCheckError as e: + return _redirect_or_error(e, fsps) + except IsADirectoryError: + return {"error": "Cannot write file content to a directory", "status_code": 400} + except FileNotFoundError: + return {"error": f"Parent directory does not exist: {fsp_name}/{subpath}", "status_code": 404} + except PermissionError: + return {"error": f"Permission denied: {fsp_name}/{subpath}", "status_code": 403} + + @action("head_file") @with_filestore def _action_head_file(request: dict, ctx: WorkerContext, filestore, fsps) -> dict: diff --git a/fileglancer/utils.py b/fileglancer/utils.py index 53e8f5f1..264d3a0f 100644 --- a/fileglancer/utils.py +++ b/fileglancer/utils.py @@ -21,6 +21,39 @@ def format_timestamp(timestamp): return dt.isoformat() +def make_etag(last_modified: float, size: int) -> str: + """Strong ETag for a file, derived from its mtime and size. + + Consistent across the read (GET/HEAD) and write (PUT precondition) paths + because all of them feed the same stat fields in here. + + ponytail: mtime granularity is the ceiling — two writes within the + filesystem's mtime resolution (coarse on some NFS) that also keep the same + size would share an ETag. Switch to a content hash if that ever matters. + """ + return f'"{last_modified:.6f}-{size}"' + + +def parse_http_date_to_epoch(value: str): + """Parse an HTTP-date or ISO-8601 datetime to a POSIX timestamp (float). + + Accepts both because we emit Last-Modified in ISO form; returns None if the + value can't be parsed. + """ + from email.utils import parsedate_to_datetime + for parse in (parsedate_to_datetime, datetime.fromisoformat): + try: + dt = parse(value) + except (TypeError, ValueError): + continue + if dt is None: + continue + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + return None + + def guess_content_type(filename): """A wrapper for guess_type which deals with unknown MIME types""" content_type, _ = guess_type(filename) diff --git a/fileglancer/worker_pool.py b/fileglancer/worker_pool.py index acc41151..8d950ec9 100644 --- a/fileglancer/worker_pool.py +++ b/fileglancer/worker_pool.py @@ -299,7 +299,10 @@ def _recv_msg(self) -> dict: raise if fds: - response["_file_handle"] = os.fdopen(fds[0], "rb") + # Actions that open a file for writing (write_file) set _fd_mode so + # the fd is wrapped writable; reads default to "rb". + fd_mode = response.pop("_fd_mode", "rb") + response["_file_handle"] = os.fdopen(fds[0], fd_mode) for extra_fd in fds[1:]: try: os.close(extra_fd) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4b0cb63..d8f912c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import AppJobs from '@/components/AppJobs'; import AppLaunch from '@/components/AppLaunch'; import JobDetail from '@/components/JobDetail'; import Browse from '@/components/Browse'; +import ConnectComplete from '@/components/ConnectComplete'; import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; @@ -97,6 +98,9 @@ const AppComponent = () => { return ( + {/* Bare popup page for the external-app connect handshake; rendered + outside MainLayout so it has no header/nav chrome. */} + } path="connect-complete" /> } path="/*"> }> } index /> diff --git a/frontend/src/components/ConnectComplete.tsx b/frontend/src/components/ConnectComplete.tsx new file mode 100644 index 00000000..74c58bb7 --- /dev/null +++ b/frontend/src/components/ConnectComplete.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useAuthContext } from '@/contexts/AuthContext'; +import { useAllowedOriginsQuery } from '@/queries/authQueries'; +import logger from '@/logger'; + +const CONNECT_MESSAGE_TYPE = 'fileglancer:connected'; + +function normalizeOrigin(origin: string): string { + return origin.trim().replace(/\/+$/, ''); +} + +/** + * Bare page that completes the "connect to Fileglancer" handshake for an + * external app. The app loads this page with an `origin` query param, either in + * a popup window or a hidden iframe (`silent=1`), and listens for a postMessage + * back. + * + * - Authenticated: posts `{ authenticated: true, username }` to the app's + * origin, then closes itself if it is a popup. + * - Not authenticated, popup: forwards to the normal login flow (Okta or + * simple) with `next` set back here, so login happens in the popup. + * - Not authenticated, silent iframe: posts `{ authenticated: false }` so the + * SDK knows to escalate to a visible popup (login can't run inside an iframe). + * + * The target window is `window.opener` for a popup or `window.parent` for an + * iframe. Messages are only ever posted to an origin on the server's allowlist. + */ +export default function ConnectComplete() { + const { loading, authStatus } = useAuthContext(); + const { data: allowedOrigins, isLoading: originsLoading } = + useAllowedOriginsQuery(); + const [status, setStatus] = useState< + 'working' | 'done' | 'forbidden' | 'no-target' + >('working'); + + const { requestOrigin, silent } = useMemo(() => { + const params = new URLSearchParams(window.location.search); + const raw = params.get('origin'); + return { + requestOrigin: raw ? normalizeOrigin(raw) : null, + silent: params.get('silent') === '1' + }; + }, []); + + const originAllowed = useMemo(() => { + if (!requestOrigin || !allowedOrigins) { + return false; + } + // Same-origin (the Fileglancer UI opening its own popup) is always fine. + if (requestOrigin === normalizeOrigin(window.location.origin)) { + return true; + } + return allowedOrigins.origins.map(normalizeOrigin).includes(requestOrigin); + }, [requestOrigin, allowedOrigins]); + + useEffect(() => { + if (loading || originsLoading) { + return; + } + + if (!requestOrigin || !originAllowed) { + logger.warn( + `Connect request from disallowed or missing origin: ${requestOrigin}` + ); + setStatus('forbidden'); + return; + } + + const target: Window | null = + window.opener || (window.parent !== window ? window.parent : null); + + if (!authStatus?.authenticated) { + if (silent) { + // Can't run the login flow inside an iframe — tell the SDK to escalate. + target?.postMessage( + { type: CONNECT_MESSAGE_TYPE, authenticated: false }, + requestOrigin + ); + setStatus('working'); + return; + } + // Popup: send the user through the normal login flow, returning here. + const self = `/connect-complete?origin=${encodeURIComponent(requestOrigin)}`; + window.location.href = `/login?next=${encodeURIComponent(self)}`; + return; + } + + if (!target) { + setStatus('no-target'); + return; + } + + target.postMessage( + { + type: CONNECT_MESSAGE_TYPE, + authenticated: true, + username: authStatus.username + }, + requestOrigin + ); + setStatus('done'); + // Only a popup can (and should) close itself; the parent removes iframes. + if (window.opener) { + window.close(); + } + }, [ + loading, + originsLoading, + requestOrigin, + originAllowed, + silent, + authStatus + ]); + + let message: string; + if (status === 'forbidden') { + message = + 'This application is not authorized to connect to Fileglancer. Contact your administrator.'; + } else if (status === 'done' || status === 'no-target') { + message = 'Connected. You can close this window and return to the app.'; + } else { + message = 'Connecting to Fileglancer…'; + } + + return ( +
+
{message}
+
+ ); +} diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 10d436ca..75b800f9 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,12 +1,13 @@ import type { FormEvent } from 'react'; -import { Link, useNavigate } from 'react-router'; -import { HiQuestionMarkCircle, HiLogin } from 'react-icons/hi'; +import { useNavigate } from 'react-router'; +import { HiLogin } from 'react-icons/hi'; import FgIcon from '@/components/designSystem/atoms/FgIcon'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgFormField from '@/components/designSystem/molecules/FgFormField'; import { useAuthContext } from '@/contexts/AuthContext'; import { useSimpleLoginMutation } from '@/queries/authQueries'; +import { isConnectLoginPopup } from '@/utils'; import { useEffect } from 'react'; import FgInput from './designSystem/atoms/formElements/FgInput'; @@ -51,35 +52,26 @@ export default function Login() { return
Loading...
; } + // Halve the spacing inside the compact connect popup so text doesn't wrap + // and overflow the small window. + const bare = isConnectLoginPopup(); + return ( -
-

+
+

Welcome to Fileglancer

-

- A powerful file browser and management tool +

+ Work with your data right where it lives.

-
- - -
-

- Help & Documentation -

-

- Learn more about Fileglancer and how to use it -

-
- - +
{isSimpleAuth ? (

Log In

diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 09306c41..47dd6a22 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -24,10 +24,12 @@ import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; import { ServerDownOverlay } from '@/components/ui/Dialogs/ServerDownOverlay'; import { useServerHealthContext } from '@/contexts/ServerHealthContext'; +import { isConnectLoginPopup } from '@/utils'; const MainLayoutContent = () => { const { showWarningOverlay, checkHealth, nextRetrySeconds } = useServerHealthContext(); + const bare = isConnectLoginPopup(); return ( @@ -39,10 +41,12 @@ const MainLayoutContent = () => { }} />
-
- - -
+ {!bare ? ( +
+ + +
+ ) : null}
diff --git a/frontend/src/queries/authQueries.ts b/frontend/src/queries/authQueries.ts index 0eebc18f..69dd1b46 100644 --- a/frontend/src/queries/authQueries.ts +++ b/frontend/src/queries/authQueries.ts @@ -23,6 +23,34 @@ export type SimpleLoginResponse = { redirect?: string; }; +export type AllowedOrigins = { + origins: string[]; +}; + +/** + * Fetches the cross-origin allowlist so the connect popup can verify the + * requesting app's origin before posting an auth result back to it. + */ +export const useAllowedOriginsQuery = () => { + const fetchAllowedOrigins = async ({ + signal + }: QueryFunctionContext): Promise => { + return (await sendRequestAndThrowForNotOk( + '/api/auth/allowed-origins', + 'GET', + undefined, + { signal } + )) as AllowedOrigins; + }; + + return useQuery({ + queryKey: ['auth', 'allowed-origins'], + queryFn: fetchAllowedOrigins, + staleTime: Infinity, + retry: false + }); +}; + export const useAuthStatusQuery = () => { const fetchAuthStatus = async ({ signal diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 9ee0f82d..46130ef7 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -454,3 +454,17 @@ export { tailLines, exitCodeMeaning } from './jobDisplay'; + +/** + * True when the login page is being shown inside the external-app connect + * popup (redirected here from /connect-complete). Used to render a bare, + * compact login with no navbar chrome. + */ +export function isConnectLoginPopup(): boolean { + const next = new URLSearchParams(window.location.search).get('next'); + return ( + window.location.pathname.endsWith('/login') && + !!next && + next.startsWith('/connect-complete') + ); +} diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 00000000..f01709a2 --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,218 @@ +"""Tests for the programmatic-API cross-origin allowlist. + +Covers the pure origin-check logic (auth.is_origin_allowed) and the end-to-end +enforcement on a cookie-authenticated endpoint (get_current_user -> +enforce_request_origin). +""" +import os +import tempfile +import shutil +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from fileglancer import auth +from fileglancer.settings import Settings +from fileglancer.server import create_app +from fileglancer.database import ( + create_engine, + sessionmaker, + Base, + FileSharePathDB, +) + + +def _req(headers: dict): + """Minimal stand-in for a Starlette Request for is_origin_allowed(). + + The function only reads request.headers.get('origin'/'host') with lowercase + keys, so a plain lowercase-keyed dict is sufficient. + """ + return SimpleNamespace(headers={k.lower(): v for k, v in headers.items()}) + + +def _settings(origins): + return SimpleNamespace(api_allowed_origins=origins) + + +# --- Unit tests: is_origin_allowed ----------------------------------------- + +def test_origin_allowed_when_no_origin_header(): + # Same-origin GETs and non-browser clients send no Origin -> allowed. + assert auth.is_origin_allowed(_req({"host": "fileglancer.int.janelia.org"}), + _settings([])) is True + + +def test_origin_allowed_when_same_origin(): + # Origin netloc matches the Host the client addressed -> the UI calling + # its own API. Allowed regardless of the allowlist. + req = _req({ + "host": "fileglancer.int.janelia.org", + "origin": "https://fileglancer.int.janelia.org", + }) + assert auth.is_origin_allowed(req, _settings([])) is True + + +def test_origin_allowed_when_in_allowlist(): + req = _req({ + "host": "fileglancer.int.janelia.org", + "origin": "https://ai-cryoet.int.janelia.org", + }) + settings = _settings(["https://ai-cryoet.int.janelia.org"]) + assert auth.is_origin_allowed(req, settings) is True + + +def test_origin_rejected_when_not_allowlisted(): + req = _req({ + "host": "fileglancer.int.janelia.org", + "origin": "https://evil.example.com", + }) + settings = _settings(["https://ai-cryoet.int.janelia.org"]) + assert auth.is_origin_allowed(req, settings) is False + + +def test_origin_allowlist_ignores_trailing_slash(): + req = _req({ + "host": "fileglancer.int.janelia.org", + "origin": "https://ai-cryoet.int.janelia.org", + }) + settings = _settings(["https://ai-cryoet.int.janelia.org/"]) + assert auth.is_origin_allowed(req, settings) is True + + +def test_origin_allowlist_respects_port(): + # Dev topology: subdomain plus explicit port must match exactly. + req = _req({ + "host": "nextflow.int.janelia.org:8443", + "origin": "https://nextflow.int.janelia.org:8444", + }) + settings = _settings(["https://nextflow.int.janelia.org:8444"]) + assert auth.is_origin_allowed(req, settings) is True + + # A different port is a different origin and is not allowed. + req_wrong = _req({ + "host": "nextflow.int.janelia.org:8443", + "origin": "https://nextflow.int.janelia.org:9999", + }) + assert auth.is_origin_allowed(req_wrong, settings) is False + + +def test_origin_scheme_matters(): + # http vs https are distinct origins; only the exact allowlisted one passes. + req = _req({ + "host": "fileglancer.int.janelia.org", + "origin": "http://ai-cryoet.int.janelia.org", + }) + settings = _settings(["https://ai-cryoet.int.janelia.org"]) + assert auth.is_origin_allowed(req, settings) is False + + +# --- Integration test: enforcement through get_current_user ------------------ + +@pytest.fixture +def auth_app(): + """A real app with simple auth, a stable session key, and an allowlist.""" + temp_dir = tempfile.mkdtemp() + db_path = os.path.join(temp_dir, "test.db") + db_url = f"sqlite:///{db_path}" + + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db_session = Session() + Base.metadata.create_all(engine) + db_session.add(FileSharePathDB( + name="tempdir", zone="z", group="g", storage="local", + mount_path=temp_dir, mac_path="", windows_path="", linux_path="", + )) + db_session.commit() + + settings = Settings( + db_url=db_url, + file_share_mounts=[], + cli_mode=True, + enable_okta_auth=False, + session_secret_key="test-secret-key", + session_cookie_secure=False, # TestClient uses http; allow cookie round-trip + api_allowed_origins=["https://ai-cryoet.int.janelia.org"], + ) + + # get_current_user resolves settings via get_settings() at request time. + # server.py binds that name at import, so patch it there too (not just in + # the settings/database modules) — otherwise the real endpoint would look up + # the session in the default database instead of this test's. + import fileglancer.settings + import fileglancer.database + import fileglancer.server + original = fileglancer.settings.get_settings + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + fileglancer.server.get_settings = lambda: settings + + app = create_app(settings) + yield app + + db_session.close() + engine.dispose() + from fileglancer.database import dispose_engine + dispose_engine(db_url) + fileglancer.settings.get_settings = original + fileglancer.database.get_settings = original + fileglancer.server.get_settings = original + shutil.rmtree(temp_dir) + + +@pytest.fixture +def authed_client(auth_app): + """A TestClient that has logged in via simple auth (cookie in jar).""" + client = TestClient(auth_app) + resp = client.post("/api/auth/simple-login", json={"username": "testuser"}) + assert resp.status_code == 200 + return client + + +def test_allowed_origins_endpoint_is_public(auth_app): + client = TestClient(auth_app) + resp = client.get("/api/auth/allowed-origins") + assert resp.status_code == 200 + assert resp.json() == {"origins": ["https://ai-cryoet.int.janelia.org"]} + + +def test_authed_request_without_origin_allowed(authed_client): + resp = authed_client.get("/api/preference") + assert resp.status_code == 200 + + +def test_authed_request_same_origin_allowed(authed_client): + # TestClient's Host is "testserver"; a matching Origin is same-origin. + resp = authed_client.get( + "/api/preference", headers={"Origin": "http://testserver"} + ) + assert resp.status_code == 200 + + +def test_authed_request_allowlisted_origin_allowed(authed_client): + resp = authed_client.get( + "/api/preference", + headers={"Origin": "https://ai-cryoet.int.janelia.org"}, + ) + assert resp.status_code == 200 + + +def test_authed_request_disallowed_origin_rejected(authed_client): + resp = authed_client.get( + "/api/preference", + headers={"Origin": "https://evil.example.com"}, + ) + assert resp.status_code == 403 + + +def test_disallowed_origin_rejected_before_auth(auth_app): + # Even without a session, a cross-origin request from a disallowed origin is + # rejected with 403 (origin check runs before the 401 auth check). + client = TestClient(auth_app) + resp = client.get( + "/api/preference", + headers={"Origin": "https://evil.example.com"}, + ) + assert resp.status_code == 403 diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index ab43517b..9eb85595 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -848,9 +848,229 @@ def test_get_file_content_directory_error(test_client, temp_dir): response = test_client.get("/api/content/tempdir?subpath=test_directory") assert response.status_code == 400 - data = response.json() - assert "error" in data - assert "directory" in data["error"].lower() + + +def test_put_file_content_creates_file(test_client, temp_dir): + """PUT to /api/content writes body to a new file.""" + content = "hello from the programmatic API" + response = test_client.put( + "/api/content/tempdir?subpath=written.txt", + content=content.encode("utf-8"), + ) + assert response.status_code == 200 + body = response.json() + assert body["bytes_written"] == len(content.encode("utf-8")) + + written_path = os.path.join(temp_dir, "written.txt") + assert os.path.exists(written_path) + with open(written_path) as f: + assert f.read() == content + + +def test_put_file_content_overwrites_file(test_client, temp_dir): + """PUT replaces the contents of an existing file (truncating).""" + target = os.path.join(temp_dir, "overwrite.txt") + with open(target, "w") as f: + f.write("original much longer content") + + new_content = "new" + response = test_client.put( + "/api/content/tempdir?subpath=overwrite.txt", + content=new_content.encode("utf-8"), + ) + assert response.status_code == 200 + with open(target) as f: + assert f.read() == new_content + + +def test_put_file_content_binary(test_client, temp_dir): + """PUT writes binary bodies byte-for-byte.""" + data = bytes(range(256)) + response = test_client.put( + "/api/content/tempdir?subpath=blob.bin", + content=data, + ) + assert response.status_code == 200 + with open(os.path.join(temp_dir, "blob.bin"), "rb") as f: + assert f.read() == data + + +def test_put_file_content_into_subdir(test_client, temp_dir): + """PUT writes into an existing subdirectory.""" + os.makedirs(os.path.join(temp_dir, "sub")) + response = test_client.put( + "/api/content/tempdir?subpath=sub/nested.txt", + content=b"nested", + ) + assert response.status_code == 200 + assert os.path.exists(os.path.join(temp_dir, "sub", "nested.txt")) + + +def test_put_file_content_missing_parent_dir(test_client, temp_dir): + """PUT to a path whose parent directory doesn't exist returns 404.""" + response = test_client.put( + "/api/content/tempdir?subpath=does_not_exist/file.txt", + content=b"data", + ) + assert response.status_code == 404 + + +def test_put_file_content_to_directory_fails(test_client, temp_dir): + """PUT targeting an existing directory returns 400.""" + os.makedirs(os.path.join(temp_dir, "adir")) + response = test_client.put( + "/api/content/tempdir?subpath=adir", + content=b"data", + ) + assert response.status_code == 400 + + +def test_put_file_content_path_traversal_blocked(test_client, temp_dir): + """PUT with a traversal subpath is rejected before touching the filesystem.""" + response = test_client.put( + "/api/content/tempdir?subpath=../escape.txt", + content=b"data", + ) + assert response.status_code == 400 + assert not os.path.exists(os.path.join(os.path.dirname(temp_dir), "escape.txt")) + + +def test_get_content_emits_etag_and_last_modified(test_client, temp_dir): + """GET content returns an ETag and Last-Modified for concurrency validators.""" + with open(os.path.join(temp_dir, "etagged.txt"), "w") as f: + f.write("v1") + response = test_client.get("/api/content/tempdir?subpath=etagged.txt") + assert response.status_code == 200 + assert response.headers.get("ETag") + assert response.headers.get("Last-Modified") + + +def test_put_if_match_matches_succeeds(test_client, temp_dir): + """PUT with a current If-Match ETag succeeds.""" + with open(os.path.join(temp_dir, "oc.txt"), "w") as f: + f.write("v1") + etag = test_client.get("/api/content/tempdir?subpath=oc.txt").headers["ETag"] + + response = test_client.put( + "/api/content/tempdir?subpath=oc.txt", + content=b"v2", + headers={"If-Match": etag}, + ) + assert response.status_code == 200 + with open(os.path.join(temp_dir, "oc.txt")) as f: + assert f.read() == "v2" + + +def test_put_if_match_stale_returns_412_and_preserves_file(test_client, temp_dir): + """A stale If-Match ETag returns 412 and leaves the file untouched.""" + target = os.path.join(temp_dir, "oc2.txt") + with open(target, "w") as f: + f.write("original") + + response = test_client.put( + "/api/content/tempdir?subpath=oc2.txt", + content=b"clobbered", + headers={"If-Match": '"stale-etag-value"'}, + ) + assert response.status_code == 412 + with open(target) as f: + assert f.read() == "original" # untouched + + +def test_put_if_match_star_requires_existence(test_client, temp_dir): + """If-Match: * on a missing file returns 412 (nothing to match).""" + response = test_client.put( + "/api/content/tempdir?subpath=missing.txt", + content=b"data", + headers={"If-Match": "*"}, + ) + assert response.status_code == 412 + assert not os.path.exists(os.path.join(temp_dir, "missing.txt")) + + +def test_put_if_unmodified_since_stale_returns_412(test_client, temp_dir): + """If-Unmodified-Since older than the file's mtime returns 412.""" + target = os.path.join(temp_dir, "oc3.txt") + with open(target, "w") as f: + f.write("original") + # A date well in the past — the file was modified after it. + response = test_client.put( + "/api/content/tempdir?subpath=oc3.txt", + content=b"clobbered", + headers={"If-Unmodified-Since": "Wed, 01 Jan 2020 00:00:00 GMT"}, + ) + assert response.status_code == 412 + with open(target) as f: + assert f.read() == "original" + + +def test_put_if_unmodified_since_fresh_succeeds(test_client, temp_dir): + """If-Unmodified-Since matching the file's Last-Modified succeeds.""" + with open(os.path.join(temp_dir, "oc4.txt"), "w") as f: + f.write("v1") + last_modified = test_client.get( + "/api/content/tempdir?subpath=oc4.txt" + ).headers["Last-Modified"] + + response = test_client.put( + "/api/content/tempdir?subpath=oc4.txt", + content=b"v2", + headers={"If-Unmodified-Since": last_modified}, + ) + assert response.status_code == 200 + + +def _set_max_upload(value): + """Set max_upload_size_bytes on the active (test-patched) settings object.""" + from fileglancer.settings import get_settings + settings = get_settings() + prev = settings.max_upload_size_bytes + settings.max_upload_size_bytes = value + return settings, prev + + +def test_put_upload_over_limit_content_length_413(test_client, temp_dir): + """A declared Content-Length over the limit is rejected before the file opens.""" + settings, prev = _set_max_upload(10) + try: + response = test_client.put( + "/api/content/tempdir?subpath=toobig.txt", + content=b"x" * 100, + ) + assert response.status_code == 413 + assert not os.path.exists(os.path.join(temp_dir, "toobig.txt")) + finally: + settings.max_upload_size_bytes = prev + + +def test_put_upload_over_limit_streaming_413(test_client, temp_dir): + """An upload exceeding the limit is rejected (413), honest or chunked.""" + settings, prev = _set_max_upload(10) + try: + def gen(): + for _ in range(5): + yield b"x" * 8 # 40 bytes total + + response = test_client.put( + "/api/content/tempdir?subpath=toobig_stream.txt", + content=gen(), + ) + assert response.status_code == 413 + finally: + settings.max_upload_size_bytes = prev + + +def test_put_upload_within_limit_succeeds(test_client, temp_dir): + """An upload under the limit still succeeds.""" + settings, prev = _set_max_upload(1000) + try: + response = test_client.put( + "/api/content/tempdir?subpath=ok.txt", + content=b"small", + ) + assert response.status_code == 200 + finally: + settings.max_upload_size_bytes = prev def test_get_file_content_not_found(test_client):