# @imaps/sync-server-auth

Sign users in to an International Mapping **Sync Server** from **any** browser app — plain HTML, React, Vue, Svelte, Astro, ArcGIS Maps SDK, MapLibre — with a few lines and no framework dependency.

- **Zero dependencies**, under 4 kB gzipped.
- Popup sign-in flow (the server hosts the completion page — **no callback HTML file in your app**).
- Token persistence (`localStorage` / `sessionStorage` / memory / your own) with expiry handling and cross-tab sync.
- Authenticated `fetch` that re-authenticates **once** on `401 / 403 / 499`, even under a burst of failures.
- External-store API (`subscribe` / `getSnapshot`) that drops straight into `useSyncExternalStore`, Vue refs, Svelte stores, nanostores…
- **ArcGIS Maps SDK**: wired automatically when loaded from the CDN; one import path for npm builds. **MapLibre / Mapbox GL**: `transformRequest` and `refreshSource` built in.
- Ships ESM + types and a `<script>`-tag build, both loadable straight from jsDelivr / unpkg.

## Install

```sh
npm i @imaps/sync-server-auth
```

**No build step?** Load it straight from the npm CDN. Static HTML/CSS/JS sites need nothing else:

```html
<!-- classic script: exposes window.SyncServerAuth and auto-creates SyncServerAuth.auth -->
<script src="https://cdn.jsdelivr.net/npm/@imaps/sync-server-auth@alpha/dist/sync-server-auth.iife.js"
        data-server="https://sync.example.com"></script>

<!-- or as an ES module -->
<script type="module">
	import { createSyncAuth } from 'https://cdn.jsdelivr.net/npm/@imaps/sync-server-auth@alpha/dist/index.js';
	const auth = createSyncAuth({ server: 'https://sync.example.com' });
</script>
```

`@alpha` tracks the newest pre-release; pin an exact version (`@0.0.1-alpha.1`) for reproducible builds. The `dist/` files use relative imports only, so `index.js`, `arcgis.js`, and `maplibre.js` all load directly from jsDelivr or unpkg (use the plain file paths, not `/+esm`).

## Pick your platform by import path

All three entry points export the same `createSyncAuth(options)`; the platform-specific ones return a client with the wiring already done.

| Import | What you get |
| --- | --- |
| `@imaps/sync-server-auth` | Core client. Auto-wires the ArcGIS SDK when it is loaded from the CDN. |
| `@imaps/sync-server-auth/arcgis` | Client pre-wired into `IdentityManager` + request interceptor for npm `@arcgis/core`. |
| `@imaps/sync-server-auth/maplibre` | Client with `transformRequest` and `refreshSource` for MapLibre / Mapbox GL. |

In the script-tag build, choose with `data-platform="arcgis"` or `data-platform="maplibre"`, or call `SyncServerAuth.arcgis.createSyncAuth(...)` / `SyncServerAuth.maplibre.createSyncAuth(...)` yourself.

## Quick start

```js
import { createSyncAuth } from '@imaps/sync-server-auth';

const auth = createSyncAuth({ server: 'https://sync.example.com' });

signInButton.onclick = () => auth.signIn(); // must run from a user gesture (popup blockers)

auth.subscribe((session) => {
	app.hidden = !session;
	who.textContent = session ? session.user.name ?? session.user.email : '';
});

// Later — token attached, re-auth handled:
const res = await auth.fetch(`${auth.featureServerUrl(42, 'Facilities')}/0/query?where=1%3D1&outFields=*&f=json`);
const { features } = await res.json();
```

If a valid session was persisted from an earlier visit, `auth.session` is already populated when `createSyncAuth` returns — no popup needed.

### Plain HTML site (no bundler)

With `data-server` set on the script tag, a client is created for you at `SyncServerAuth.auth`, and two data attributes cover the common UI:

```html
<script src=".../sync-server-auth.iife.js" data-server="https://sync.example.com"></script>

<button data-sync-auth="signin" data-sync-auth-when="signed-out">Sign in</button>
<button data-sync-auth="signout" data-sync-auth-when="signed-in">Sign out</button>
<main data-sync-auth-when="signed-in">…</main>

<script>
	const { auth } = SyncServerAuth;
	auth.subscribe(async (session) => {
		if (!session) return;
		const res = await auth.fetch(auth.geojsonUrl(42, 'Facilities'));
		render(await res.json());
	});
</script>
```

Add `data-platform="arcgis"` or `data-platform="maplibre"` to get the platform-wired client instead, and `data-storage="session"` or `"memory"` to change persistence. Everything from the ESM API is on the `SyncServerAuth` global too: `SyncServerAuth.createSyncAuth`, `SyncServerAuth.arcgis.createSyncAuth`, `SyncServerAuth.maplibre.createSyncAuth`, `SyncServerAuth.SignInError`.

### React

```tsx
const auth = createSyncAuth({ server: SERVER }); // module scope, one per server

function useSession() {
	return React.useSyncExternalStore(auth.subscribe, auth.getSnapshot, auth.getServerSnapshot);
}

function Gate({ children }) {
	const session = useSession();
	if (!session) return <button onClick={() => auth.signIn()}>Sign in</button>;
	return children;
}
```

### Vue / Svelte / anything else

`auth.subscribe(cb)` fires on every change and returns an unsubscribe function; `auth.session` is the current value. That is all a `shallowRef`, a Svelte `readable`, or a nanostores `atom` needs.

## MapLibre GL / Mapbox GL

```js
import { createSyncAuth } from '@imaps/sync-server-auth/maplibre';

const auth = createSyncAuth({ server: 'https://sync.example.com' });
await auth.ensureSignedIn(); // transformRequest is synchronous, so sign in before the map loads

const map = new maplibregl.Map({ container: 'map', style, transformRequest: auth.transformRequest });

map.addSource('parcels', { type: 'vector', tiles: [`${auth.vectorTileServerUrl(42, 'Parcels')}/{z}/{x}/{y}.pbf`] });

// after an edit or a re-authentication:
auth.refreshSource(map.getSource('parcels'));
```

`transformRequest` adds `?token=` to Sync Server URLs and leaves everything else alone, reading the token per request so sign-in/out is picked up live. `transformRequestWithHeader` sends `Authorization: Bearer` instead. `refreshSource` re-sets a GeoJSON source or cache-busts a vector source's tiles.

## ArcGIS Maps SDK for JavaScript

**SDK from the CDN** (`<script src="https://js.arcgis.com/5.1/">`, including map components): nothing to do. The core client detects `$arcgis.import`, registers the token with `IdentityManager`, and intercepts every request to Sync Server. Set `arcgis: false` to opt out.

**SDK from npm** (`@arcgis/core` through a bundler): import from the `/arcgis` entry instead.

```js
import { createSyncAuth } from '@imaps/sync-server-auth/arcgis';

const auth = createSyncAuth({ server: 'https://sync.example.com' });
// FeatureLayers pointed at Sync Server carry the token; a 401/403/499 re-opens the popup once.
```

`@arcgis/core` is an optional peer dependency and is imported for you. If it isn't resolvable, pass the modules in via `arcgis: { esriConfig, esriId }`. `connectArcgis(auth)` is also exported for wiring an existing client; both are idempotent per client.

## API

```ts
const auth = createSyncAuth({
	server: 'https://sync.example.com', // required — origin only is used
	storage: 'local' | 'session' | 'memory' | Storage, // default 'local'
	storageKey: 'imaps:sync-auth',
	popup: { width: 520, height: 640, name: 'imaps-sync-auth' },
	arcgis: true, // auto-wire the CDN-loaded ArcGIS SDK when detected
});

// state
auth.session; // AuthSession | null   { token, expires (ms), user: { id, name, email, image } }
auth.isSignedIn; auth.token; auth.user;
auth.subscribe(fn) → unsubscribe;  auth.getSnapshot(); auth.getServerSnapshot();

// sign in / out
await auth.signIn();           // popup; concurrent calls share one popup
await auth.ensureSignedIn();   // popup only if there is no session
await auth.reauthenticate();   // drop + sign in, single-flight
await auth.signOut();          // clears local session, best-effort ends the server session

// using the token
await auth.fetch(url, init & { interactive?: boolean });  // bearer header + one retry on 401/403/499
auth.withToken(url);   // adds ?token= for Sync Server URLs only
auth.featureServerUrl(baseId, table?); auth.vectorTileServerUrl(baseId, table); auth.geojsonUrl(baseId, table);

auth.destroy();
```

`signIn()` rejects with a `SignInError` whose `code` is one of `popup-blocked`, `cancelled`, `server-error`, `invalid-response`, `not-signed-in`, `redirecting`.

### Popup vs. redirect

```ts
const auth = createSyncAuth({ server, mode: 'auto' }); // default

await auth.signIn();                       // popup; if it is blocked, falls back to a full-page redirect
await auth.signIn({ mode: 'popup' });      // popup only — rejects with `popup-blocked`
await auth.signIn({ mode: 'redirect' });   // always navigate this window
await auth.signInWithRedirect();           // same as the line above
```

The redirect flow sends the user to the sign-in page and back to `returnTo` (default: the current page URL) with the session in the URL **fragment** (`#imaps_auth=…`). Fragments never reach a server or its logs; `createSyncAuth` reads the fragment on the next page load, stores the session, and scrubs it from the address bar and history. The promise from a redirect never settles — the page is unloading — so put post-sign-in logic in `subscribe` or a plain `if (auth.isSignedIn)` on load instead of `.then`.

The popup URL also carries `returnTo`, so a **magic link opened in another tab** (where there is no popup to report back to) finishes by redirecting to your app instead of dead-ending.

**The app's origin must be on the server's redirect allow-list** (`authRedirectOrigins` in the Sync Server config, or the `AUTH_REDIRECT_ORIGINS` env). A token-bearing redirect to an unlisted origin is refused with an explanation page; a CORS `*` is deliberately not enough.

## How it works

1. `signIn()` opens `{server}/api/v0/auth/signin?callbackUrl={server}/auth/popup-complete`.
2. The user signs in with whatever providers the deployment offers.
3. The server-hosted `popup-complete` page mints an **app token** (short-lived JWT) and `postMessage`s `{ type: 'imaps:auth', token, expires, user }` to your window, then closes.
4. This library accepts the message **only from the server origin**, stores the session, and notifies subscribers.

Redirect flow: `signIn({ mode: 'redirect' })` navigates to the same sign-in page with `callbackUrl={server}/api/v0/auth/complete?returnTo={your page}`; that endpoint checks `returnTo` against the allow-list, mints the token and 302s to `{your page}#imaps_auth=<base64url session>`.

Your app's origin must be on the Sync Server CORS allow list, and users must be members of the org that owns the base — a valid sign-in without membership still gets `401` on data requests.

## Security notes

- The token is a bearer credential. `localStorage` is readable by any script on your origin; if XSS is in your threat model, use `storage: 'memory'` and accept a popup on reload.
- Never put an API key (`ims_…`) in browser code — use this sign-in flow for people and API keys for servers.

## License

MIT © International Mapping Associates, Inc.
