diff --git a/.github/workflows/native-desktop-example.yaml b/.github/workflows/native-desktop-example.yaml
new file mode 100644
index 0000000..b608dc5
--- /dev/null
+++ b/.github/workflows/native-desktop-example.yaml
@@ -0,0 +1,83 @@
+name: native-desktop-example
+
+# Keeps examples/native-desktop running: the Electron app and its backend against the OpenID
+# Provider local recipe, with a virtual authenticator standing in for the user's passkey. The weekly
+# run catches a change in the pinned images or the published packages breaking the example.
+on:
+ pull_request:
+ paths:
+ - 'examples/native-desktop/**'
+ - '.github/workflows/native-desktop-example.yaml'
+ schedule:
+ - cron: '41 5 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ # The recipe builds the API from source until API v0.23.0 is published
+ # (fells-code/seamless-auth-api#561), since v0.22.0 has no client link routes.
+ RECIPE_REF: 30be59b557ad109a93aff68695f0fb21ff2ef84d
+
+jobs:
+ example:
+ name: Desktop app and backend, end to end
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ defaults:
+ run:
+ working-directory: examples/native-desktop
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Checkout the local recipe
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: fells-code/seamless-auth-oidc-provider
+ ref: ${{ env.RECIPE_REF }}
+ path: .recipe
+
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'npm'
+ cache-dependency-path: examples/native-desktop/package-lock.json
+
+ - name: Bring the recipe up
+ run: '${GITHUB_WORKSPACE}/.recipe/local/up.sh'
+
+ - name: Install the example
+ run: |
+ npm ci
+ sudo apt-get update
+ sudo apt-get install -y xvfb dbus-x11 gnome-keyring
+ npx playwright install --with-deps chromium
+ # Ubuntu 24.04 restricts the user namespaces Electron's sandbox uses.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+
+ - name: Turn notices on for the backend client
+ run: node scripts/enable-notices.mjs "${GITHUB_WORKSPACE}/.recipe/local"
+
+ - name: Run the example end to end
+ env:
+ RECIPE_DIR: ${{ github.workspace }}/.recipe/local
+ run: xvfb-run -a dbus-run-session -- bash e2e/with-keyring.sh node e2e/run.mjs
+
+ - name: Service logs
+ if: failure()
+ working-directory: .recipe/local
+ run: docker compose --env-file settings.env logs --no-color --tail 200
+
+ - name: Upload screenshots
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: native-desktop-screenshots
+ path: examples/native-desktop/e2e/screenshots/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/examples/native-desktop/.gitignore b/examples/native-desktop/.gitignore
new file mode 100644
index 0000000..2542cfc
--- /dev/null
+++ b/examples/native-desktop/.gitignore
@@ -0,0 +1 @@
+e2e/screenshots/
diff --git a/examples/native-desktop/README.md b/examples/native-desktop/README.md
new file mode 100644
index 0000000..35dc74a
--- /dev/null
+++ b/examples/native-desktop/README.md
@@ -0,0 +1,114 @@
+# Native desktop example
+
+An Electron app and the small API behind it, on the Seamless Auth OpenID Provider. It is the
+runnable half of the [native apps guide](https://docs.seamlessauth.com/build/native-apps-oidc), and
+its end-to-end run doubles as a conformance check for the pattern in
+[ADR 0002](https://github.com/fells-code/seamless-auth-api/blob/main/docs/adr/0002-native-and-desktop-apps.md).
+Nothing in it is specific to one product. "Desk Notes" is a stand-in.
+
+| Path | What it is |
+| ------------------------ | ---------------------------------------------------------------------------------------- |
+| `app/oidc-session.mjs` | System browser, loopback listener, PKCE, code exchange, refresh, revocation, end session |
+| `app/token-store.mjs` | The refresh token in the OS credential store, with no file fallback |
+| `app/backend-client.mjs` | Calls the backend, and follows an RFC 9470 challenge back through the browser |
+| `app/main.mjs` | The Electron main process. Tokens stay here. The window only shows the app's own page |
+| `backend/server.mjs` | Verify-only guards from `@seamless-auth/express`, one scope route, one step-up route |
+| `backend/notices.mjs` | Client credentials with the backend's own key, and a notice through the client link |
+| `scripts/` | Reads the local recipe's settings, and turns notices on for the backend client |
+| `e2e/run.mjs` | The whole thing, driven by Playwright |
+
+## Needs
+
+- Node 22.12 or newer, and Docker with Compose for the local instance.
+- The [local recipe](https://github.com/fells-code/seamless-auth-oidc-provider/pull/60) of
+ `seamless-auth-oidc-provider`, at commit `30be59b`.
+- **Seamless Auth API v0.23.0 for the notice, which is not published yet.** The recipe builds the API
+ from source at a pinned commit until then, because v0.22.0 has no client link routes. Sign-in,
+ refresh, revocation and step-up do not depend on links.
+- Edge image `v0.3.0`, the first with `acr`, `amr` and `auth_time` on access tokens, and
+ `@seamless-auth/express` 0.24.0. The recipe and `package.json` pin both.
+
+## Run it
+
+```bash
+# In a checkout of seamless-auth-oidc-provider, at the commit above.
+local/up.sh
+export RECIPE=$PWD/local
+
+# Here.
+npm ci
+node scripts/enable-notices.mjs "$RECIPE"
+```
+
+`enable-notices.mjs` is there because the recipe's seed registers the backend client and its link
+but does not set `noticesEnabled`. Then, in two terminals:
+
+```bash
+eval "$(node scripts/recipe-env.mjs backend "$RECIPE")"
+npm run backend
+```
+
+```bash
+eval "$(node scripts/recipe-env.mjs app "$RECIPE")"
+npm run app
+```
+
+The app opens your default browser for sign-in, so that browser must trust the recipe's local CA.
+The recipe's README shows how, and that is your decision to make: nothing here does it for you. Read
+the sign-in code at the recipe's mailbox, `http://127.0.0.1:9025` by default. The app's settings are
+environment variables so the two sides stay visibly separate. A real app would compile them in, and
+none of them is a secret.
+
+## Run the check
+
+```bash
+npx playwright install chromium
+RECIPE_DIR="$RECIPE" npm run e2e
+```
+
+On Linux with no desktop session, it needs a display and a Secret Service:
+
+```bash
+xvfb-run -a dbus-run-session -- bash e2e/with-keyring.sh node e2e/run.mjs
+```
+
+[CI](../../.github/workflows/native-desktop-example.yaml) runs that on pull requests that touch this
+directory and every week.
+
+| It runs for real | It stands in |
+| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| The Electron app, with its main and renderer processes and its sandboxed window | The default browser: Chromium from Playwright, handed the URL that `shell.openExternal` would have opened |
+| The OS keychain entry, read from outside the app | The passkey: a virtual authenticator in that Chromium |
+| The backend, the adapter from npm, the edge and the API | The mail provider: the recipe's mailbox |
+
+It checks the authorization request (PKCE `S256`, no secret, loopback on an OS-picked port), that the
+listener refuses a wrong `state` and is not reachable off loopback, that the refresh token is in the
+keychain and in no file the app wrote, the RFC 9470 challenge and the retry, that a stale `auth_time`
+challenges again, a notice that names the desktop app, that the backend refuses another client's
+valid token, a new launch restoring from the keychain, sign-out with revocation, and signing out of
+Seamless.
+
+It does not check:
+
+- The OS actually opening a browser, or a real passkey or security key.
+- Windows Credential Manager, or a packaged and signed build of the app.
+- A private-use scheme redirect. The example uses loopback.
+- Refreshing from several processes at once. The app holds a single instance lock, and that is all.
+- A released API. Until v0.23.0, it runs on the commit the recipe pins.
+
+## Why Electron
+
+Tauri would suit this pattern just as well, and it is smaller once built. Electron is here because it
+is the cheaper one to keep running in CI:
+
+- [Playwright drives Electron directly](https://playwright.dev/docs/api/class-electron), with the
+ same tool that drives the hosted page and its virtual authenticator, and it can replace
+ `shell.openExternal` from the test with no test code in the app.
+- Tauri needs a Rust build and WebKitGTK on the Linux runner, and its
+ [WebDriver support](https://tauri.app/develop/tests/webdriver/) needs a separate driver.
+- The main process is Node, so the app and the backend share `openid-client`.
+
+## Why it lives here
+
+This repository holds the guide it belongs to, and no plan phase calls for a new repository. The
+example is a directory with its own `package.json`, so nothing it installs reaches the docs site.
diff --git a/examples/native-desktop/app/backend-client.mjs b/examples/native-desktop/app/backend-client.mjs
new file mode 100644
index 0000000..8561dc3
--- /dev/null
+++ b/examples/native-desktop/app/backend-client.mjs
@@ -0,0 +1,43 @@
+/** Reads an RFC 6750 or RFC 9470 challenge, whose values may be quoted or bare tokens. */
+export function parseChallenge(header) {
+ if (!header?.toLowerCase().startsWith('bearer')) return {}
+ const challenge = {}
+ for (const [, name, quoted, bare] of header.matchAll(/([a-z_]+)=(?:"([^"]*)"|([^\s,]+))/gi)) {
+ challenge[name] = quoted ?? bare
+ }
+ return challenge
+}
+
+/**
+ * Calls the app's backend with the access token. An expired token refreshes once. An RFC 9470
+ * challenge sends the user through the system browser with what the challenge asks for, once.
+ */
+export function createBackendClient({ baseUrl, session }) {
+ const send = async (method, path) =>
+ fetch(new URL(path, baseUrl), {
+ method,
+ headers: { authorization: `Bearer ${await session.accessToken()}` },
+ })
+
+ return async function call(method, path) {
+ let response = await send(method, path)
+ let stepUp
+
+ if (response.status === 401) {
+ const challenge = parseChallenge(response.headers.get('www-authenticate'))
+ if (challenge.error === 'insufficient_user_authentication') {
+ stepUp = { acrValues: challenge.acr_values, maxAge: challenge.max_age }
+ await session.signIn({
+ acrValues: challenge.acr_values,
+ maxAge: challenge.max_age === undefined ? undefined : Number(challenge.max_age),
+ })
+ response = await send(method, path)
+ } else if (challenge.error === 'invalid_token') {
+ await session.refresh()
+ response = await send(method, path)
+ }
+ }
+
+ return { status: response.status, body: await response.json(), stepUp }
+ }
+}
diff --git a/examples/native-desktop/app/index.html b/examples/native-desktop/app/index.html
new file mode 100644
index 0000000..7d2a210
--- /dev/null
+++ b/examples/native-desktop/app/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Desk Notes
+
+
+
+
Desk Notes
+
Loading
+
+
+
+
+
+
+
+
+
+
Last result
+
+
Access token claims
+
+
+
+
diff --git a/examples/native-desktop/app/main.mjs b/examples/native-desktop/app/main.mjs
new file mode 100644
index 0000000..88f1221
--- /dev/null
+++ b/examples/native-desktop/app/main.mjs
@@ -0,0 +1,80 @@
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+import { app, BrowserWindow, ipcMain, shell } from 'electron'
+
+import { createBackendClient } from './backend-client.mjs'
+import { createOidcSession } from './oidc-session.mjs'
+import { createTokenStore } from './token-store.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const indexUrl = pathToFileURL(join(here, 'index.html')).href
+
+function required(name) {
+ const value = process.env[name]
+ if (!value) throw new Error(`Set ${name}. See README.md.`)
+ return value
+}
+
+const issuer = required('SEAMLESS_ISSUER')
+const clientId = required('NATIVE_CLIENT_ID')
+
+const session = createOidcSession({
+ issuer,
+ clientId,
+ resource: required('API_RESOURCE'),
+ scopes: required('API_SCOPES').split(' '),
+ store: createTokenStore({
+ service: 'com.example.desknotes',
+ account: `${issuer} ${clientId}`,
+ }),
+ // The user's default browser: the app never renders the sign-in page itself.
+ openBrowser: (url) => shell.openExternal(url),
+})
+const backend = createBackendClient({ baseUrl: required('BACKEND_URL'), session })
+
+const actions = {
+ status: () => session.status(),
+ signIn: () => session.signIn(),
+ refresh: () => session.refresh(),
+ signOut: () => session.signOut(),
+ signOutEverywhere: () => session.signOutEverywhere(),
+ listNotes: () => backend('GET', '/notes'),
+ exportNotes: () => backend('POST', '/notes/export'),
+ sendNotice: () => backend('POST', '/account/notice'),
+}
+
+// Tokens stay in this process. The renderer only ever sees the results of these actions.
+ipcMain.handle('action', async (event, name) => {
+ if (event.senderFrame?.url !== indexUrl || !Object.hasOwn(actions, name)) {
+ throw new Error('Refused')
+ }
+ const result = await actions[name]()
+ return { result, status: await session.status() }
+})
+
+// One process holds the refresh token, so two windows of the app cannot race to rotate it.
+if (!app.requestSingleInstanceLock()) app.quit()
+
+app.whenReady().then(async () => {
+ const window = new BrowserWindow({
+ width: 760,
+ height: 620,
+ webPreferences: {
+ preload: join(here, 'preload.cjs'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webviewTag: false,
+ },
+ })
+ // The window shows the app's own page and nothing else, so no sign-in page can land in it.
+ window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+ window.webContents.on('will-navigate', (event) => event.preventDefault())
+ await window.loadFile(join(here, 'index.html'))
+ await session.restore().catch((error) => {
+ console.error(`Could not restore the session: ${error.message}`)
+ })
+})
+
+app.on('window-all-closed', () => app.quit())
diff --git a/examples/native-desktop/app/oidc-session.mjs b/examples/native-desktop/app/oidc-session.mjs
new file mode 100644
index 0000000..9e9678f
--- /dev/null
+++ b/examples/native-desktop/app/oidc-session.mjs
@@ -0,0 +1,218 @@
+import { createServer } from 'node:http'
+
+import * as oidc from 'openid-client'
+
+const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000
+const EXPIRY_MARGIN_MS = 30 * 1000
+
+const page = (text) =>
+ `Desk Notes
${text}
`
+
+/**
+ * The loopback listener of RFC 8252 section 7.3: a port the OS picks, on the loopback address only,
+ * for one redirect. A request with the wrong `state` is answered and ignored rather than ending the
+ * sign-in, since any local process or web page can reach this port.
+ */
+async function listenForRedirect(expectedState, signal) {
+ let deliver
+ let fail
+ const redirected = new Promise((resolve, reject) => {
+ deliver = resolve
+ fail = reject
+ })
+
+ const server = createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1')
+ const send = (status, text) => {
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', connection: 'close' })
+ res.end(page(text))
+ }
+ if (url.pathname !== '/callback') return send(404, 'Not found.')
+ if (url.searchParams.get('state') !== expectedState) {
+ return send(400, 'This response does not match a sign-in the app started.')
+ }
+ send(200, 'Back to Desk Notes. You can close this tab.')
+ deliver(url.search)
+ })
+
+ signal.addEventListener('abort', () => fail(signal.reason), { once: true })
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', resolve)
+ })
+
+ return {
+ redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
+ redirected,
+ close: () => server.close(),
+ }
+}
+
+function claimsOf(jwt) {
+ // Shown to the user only. The backend is what verifies the token.
+ return JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString())
+}
+
+/**
+ * The app's side of RFC 8252: a public client with no secret, the authorization code flow with
+ * PKCE in the system browser, the refresh token in the OS credential store and the access token
+ * in memory.
+ */
+export function createOidcSession({ issuer, clientId, resource, scopes, store, openBrowser }) {
+ let configuration
+ let access
+ let idToken
+ let refreshing
+ let abortPendingSignIn
+
+ const configure = async () => {
+ configuration ??= await oidc.discovery(new URL(issuer), clientId, undefined, oidc.None())
+ return configuration
+ }
+
+ const adopt = async (tokens) => {
+ // Save first: a crash after this leaves the app holding the live refresh token, not a spent one.
+ if (tokens.refresh_token) await store.set(tokens.refresh_token)
+ access = {
+ token: tokens.access_token,
+ expiresAt: Date.now() + (tokens.expires_in ?? 0) * 1000,
+ }
+ idToken = tokens.id_token ?? idToken
+ }
+
+ const forget = async () => {
+ access = undefined
+ idToken = undefined
+ await store.delete()
+ }
+
+ /** Signs in, or steps up when `acrValues` or `maxAge` come from a backend's RFC 9470 challenge. */
+ async function signIn({ acrValues, maxAge } = {}) {
+ const config = await configure()
+ abortPendingSignIn?.abort(new Error('Replaced by a newer sign-in'))
+ const aborter = new AbortController()
+ abortPendingSignIn = aborter
+ const timer = setTimeout(
+ () => aborter.abort(new Error('The sign-in timed out')),
+ SIGN_IN_TIMEOUT_MS,
+ )
+
+ const state = oidc.randomState()
+ const nonce = oidc.randomNonce()
+ const verifier = oidc.randomPKCECodeVerifier()
+ const listener = await listenForRedirect(state, aborter.signal)
+
+ try {
+ const parameters = {
+ redirect_uri: listener.redirectUri,
+ response_type: 'code',
+ scope: ['openid', 'email', 'offline_access', ...scopes].join(' '),
+ resource,
+ // The provider issues a refresh token for offline_access only when the user is asked.
+ prompt: 'consent',
+ state,
+ nonce,
+ code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
+ code_challenge_method: 'S256',
+ ...(acrValues ? { acr_values: acrValues } : {}),
+ ...(maxAge !== undefined ? { max_age: String(maxAge) } : {}),
+ }
+ await openBrowser(oidc.buildAuthorizationUrl(config, parameters).href)
+
+ const search = await listener.redirected
+ const tokens = await oidc.authorizationCodeGrant(
+ config,
+ new URL(`${listener.redirectUri}${search}`),
+ {
+ pkceCodeVerifier: verifier,
+ expectedState: state,
+ expectedNonce: nonce,
+ ...(maxAge !== undefined ? { maxAge } : {}),
+ },
+ { resource },
+ )
+ const superseded = await store.get()
+ await adopt(tokens)
+ // A step-up starts a new grant, so the one before it would otherwise stay usable.
+ if (superseded) {
+ await oidc
+ .tokenRevocation(config, superseded, { token_type_hint: 'refresh_token' })
+ .catch(() => {})
+ }
+ } finally {
+ clearTimeout(timer)
+ listener.close()
+ }
+ }
+
+ /** One refresh at a time: refresh tokens rotate, and a token used twice revokes the grant. */
+ function refresh() {
+ refreshing ??= (async () => {
+ const config = await configure()
+ const current = await store.get()
+ if (!current) throw new Error('Not signed in')
+ try {
+ await adopt(await oidc.refreshTokenGrant(config, current, { resource }))
+ } catch (error) {
+ if (error.error === 'invalid_grant') await forget()
+ throw error
+ }
+ })().finally(() => {
+ refreshing = undefined
+ })
+ return refreshing
+ }
+
+ async function accessToken() {
+ if (!access || access.expiresAt - EXPIRY_MARGIN_MS <= Date.now()) await refresh()
+ return access.token
+ }
+
+ /** Ends this app's grant. The Seamless session in the browser stays. */
+ async function signOut() {
+ const config = await configure()
+ const current = await store.get()
+ let revoked = false
+ try {
+ if (current) {
+ await oidc.tokenRevocation(config, current, { token_type_hint: 'refresh_token' })
+ revoked = true
+ }
+ } finally {
+ await forget()
+ }
+ return { revoked }
+ }
+
+ /**
+ * Also ends the Seamless session, which lives in the browser's cookies, so only the browser can
+ * end it. The hosted page asks the user to confirm, then shows its own signed-out page. An app
+ * that wants the browser back can pass a registered post_logout_redirect_uri here.
+ */
+ async function signOutEverywhere() {
+ const config = await configure()
+ const hint = idToken
+ const result = await signOut()
+ await openBrowser(
+ oidc.buildEndSessionUrl(config, {
+ client_id: clientId,
+ ...(hint ? { id_token_hint: hint } : {}),
+ }).href,
+ )
+ return result
+ }
+
+ async function status() {
+ const signedIn = Boolean(await store.get())
+ if (!access) return { signedIn }
+ const { sub, acr, amr, auth_time: authTime, aud, scope } = claimsOf(access.token)
+ return { signedIn, token: { sub, acr, amr, authTime, aud, scope } }
+ }
+
+ /** On launch: a stored refresh token signs the app back in with no browser. */
+ async function restore() {
+ if (await store.get()) await refresh()
+ }
+
+ return { signIn, refresh, accessToken, signOut, signOutEverywhere, status, restore }
+}
diff --git a/examples/native-desktop/app/preload.cjs b/examples/native-desktop/app/preload.cjs
new file mode 100644
index 0000000..c017b63
--- /dev/null
+++ b/examples/native-desktop/app/preload.cjs
@@ -0,0 +1,7 @@
+// A sandboxed preload script cannot be an ES module.
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { contextBridge, ipcRenderer } = require('electron')
+
+contextBridge.exposeInMainWorld('desktop', {
+ run: (action) => ipcRenderer.invoke('action', action),
+})
diff --git a/examples/native-desktop/app/renderer.js b/examples/native-desktop/app/renderer.js
new file mode 100644
index 0000000..5dd66f1
--- /dev/null
+++ b/examples/native-desktop/app/renderer.js
@@ -0,0 +1,29 @@
+const who = document.querySelector('#who')
+const result = document.querySelector('#result')
+const claims = document.querySelector('#claims')
+const buttons = [...document.querySelectorAll('button[data-action]')]
+
+function show(status) {
+ who.textContent = status.signedIn ? 'Signed in' : 'Signed out'
+ claims.textContent = status.token ? JSON.stringify(status.token, null, 2) : ''
+}
+
+async function run(action) {
+ for (const button of buttons) button.disabled = true
+ try {
+ const answer = await window.desktop.run(action)
+ show(answer.status)
+ if (action !== 'status') {
+ result.textContent = JSON.stringify({ action, ok: true, result: answer.result }, null, 2)
+ }
+ } catch (error) {
+ result.textContent = JSON.stringify({ action, ok: false, error: error.message }, null, 2)
+ } finally {
+ for (const button of buttons) button.disabled = false
+ }
+}
+
+for (const button of buttons) {
+ button.addEventListener('click', () => run(button.dataset.action))
+}
+run('status')
diff --git a/examples/native-desktop/app/styles.css b/examples/native-desktop/app/styles.css
new file mode 100644
index 0000000..12174f8
--- /dev/null
+++ b/examples/native-desktop/app/styles.css
@@ -0,0 +1,37 @@
+body {
+ font:
+ 15px/1.5 system-ui,
+ sans-serif;
+ margin: 24px;
+}
+
+h1 {
+ font-size: 22px;
+ margin: 0 0 4px;
+}
+
+h2 {
+ font-size: 14px;
+ margin: 20px 0 4px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+button {
+ font: inherit;
+ padding: 6px 12px;
+}
+
+pre {
+ background: #f4f4f5;
+ border-radius: 6px;
+ min-height: 2em;
+ overflow: auto;
+ padding: 8px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
diff --git a/examples/native-desktop/app/token-store.mjs b/examples/native-desktop/app/token-store.mjs
new file mode 100644
index 0000000..5714750
--- /dev/null
+++ b/examples/native-desktop/app/token-store.mjs
@@ -0,0 +1,16 @@
+import { AsyncEntry } from '@napi-rs/keyring'
+
+/**
+ * The refresh token in the OS credential store: the macOS Keychain, Windows Credential Manager or
+ * the Secret Service on Linux. There is deliberately no fallback. If the store is locked or absent,
+ * signing in fails and says so. The Linux store is named because the library otherwise falls back
+ * to the kernel keyring, which is memory only, so the user would be signed out at every reboot.
+ */
+export function createTokenStore({ service, account }) {
+ const entry = new AsyncEntry(service, account, { linux: { store: 'secret-service' } })
+ return {
+ get: () => entry.getPassword(),
+ set: (token) => entry.setPassword(token),
+ delete: () => entry.deleteCredential(),
+ }
+}
diff --git a/examples/native-desktop/backend/notices.mjs b/examples/native-desktop/backend/notices.mjs
new file mode 100644
index 0000000..160099d
--- /dev/null
+++ b/examples/native-desktop/backend/notices.mjs
@@ -0,0 +1,56 @@
+import { readFile } from 'node:fs/promises'
+
+import * as oidc from 'openid-client'
+
+const EXPIRY_MARGIN_MS = 30 * 1000
+
+/**
+ * Account notices through a client link (ADR 0002 N5). The backend proves itself with its own key
+ * and names the public client the user signed in to. The key stays on this server, and the auth API
+ * is reached directly: it is private, so a desktop app never could.
+ */
+export function createNoticeSender({ issuer, apiUrl, backendClientId, keyFile, nativeClientId }) {
+ let configuration
+ let cached
+
+ async function configure() {
+ if (configuration) return configuration
+ const jwk = JSON.parse(await readFile(keyFile, 'utf8'))
+ const key = await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ )
+ configuration = await oidc.discovery(
+ new URL(issuer),
+ backendClientId,
+ undefined,
+ oidc.PrivateKeyJwt({ key, kid: jwk.kid }),
+ )
+ return configuration
+ }
+
+ async function token() {
+ if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.value
+ const tokens = await oidc.clientCredentialsGrant(await configure(), {
+ resource: `${issuer}/seamless`,
+ scope: 'notices:send',
+ })
+ cached = {
+ value: tokens.access_token,
+ expiresAt: Date.now() + (tokens.expires_in ?? 0) * 1000,
+ }
+ return cached.value
+ }
+
+ return async function sendNotice(userId, { subject, text }) {
+ const response = await fetch(`${apiUrl}/applications/account-notices`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${await token()}`, 'content-type': 'application/json' },
+ body: JSON.stringify({ userId, clientId: nativeClientId, subject, text }),
+ })
+ return { status: response.status, body: await response.json() }
+ }
+}
diff --git a/examples/native-desktop/backend/server.mjs b/examples/native-desktop/backend/server.mjs
new file mode 100644
index 0000000..c4cc3f5
--- /dev/null
+++ b/examples/native-desktop/backend/server.mjs
@@ -0,0 +1,92 @@
+import { createResourceServer } from '@seamless-auth/express'
+import express from 'express'
+
+import { createNoticeSender } from './notices.mjs'
+
+function required(name) {
+ const value = process.env[name]
+ if (!value) throw new Error(`Set ${name}. See README.md.`)
+ return value
+}
+
+const issuer = required('SEAMLESS_ISSUER')
+const nativeClientId = required('NATIVE_CLIENT_ID')
+const port = Number(process.env.BACKEND_PORT ?? 8787)
+const stepUpMaxAge = Number(process.env.STEP_UP_MAX_AGE ?? 300)
+
+const api = createResourceServer({ issuer, audience: required('API_RESOURCE') })
+const sendNotice = createNoticeSender({
+ issuer,
+ apiUrl: required('SEAMLESS_API_URL'),
+ backendClientId: required('BACKEND_CLIENT_ID'),
+ keyFile: required('BACKEND_KEY_FILE'),
+ nativeClientId,
+})
+
+// The adapter checks the token. Which clients this API serves is this API's decision (ADR 0002 N3):
+// without this, any client allowed the same resource could call it, including the backend itself.
+const clients = new Set([nativeClientId])
+function requireClient(req, res, next) {
+ if (clients.has(req.user.clientId)) return next()
+ res.status(403).json({ error: 'client_not_allowed' })
+}
+
+const notesByUser = new Map()
+const notesOf = (userId) => {
+ if (!notesByUser.has(userId)) notesByUser.set(userId, [{ id: 1, text: 'Welcome to Desk Notes' }])
+ return notesByUser.get(userId)
+}
+
+const app = express()
+app.disable('x-powered-by')
+
+app.get('/health', (_req, res) => res.json({ ok: true }))
+
+app.get('/notes', api.requireAuth(), requireClient, api.requireScope('notes:read'), (req, res) => {
+ res.json({ notes: notesOf(req.user.id) })
+})
+
+// Sensitive, so it needs a phishing-resistant sign-in within the last few minutes. Until the token
+// shows one, the guard answers the RFC 9470 challenge, and the app sends the user to the browser.
+app.post(
+ '/notes/export',
+ api.requireAuth(),
+ requireClient,
+ api.requireScope('notes:read'),
+ api.requireStepUp({ acrValues: ['phr'], maxAge: stepUpMaxAge }),
+ (req, res) => {
+ res.json({
+ exported: notesOf(req.user.id),
+ signedInWith: { acr: req.user.acr, amr: req.user.amr, authTime: req.user.authTime },
+ })
+ },
+)
+
+app.post(
+ '/account/notice',
+ api.requireAuth(),
+ requireClient,
+ api.requireScope('notes:write'),
+ async (req, res, next) => {
+ try {
+ const sent = await sendNotice(req.user.id, {
+ subject: 'Your notes export is ready',
+ text: 'The export you asked for is ready in the app.',
+ })
+ res.status(sent.status).json(sent.body)
+ } catch (error) {
+ next(error)
+ }
+ },
+)
+
+// Express knows an error handler by its four parameters.
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+app.use((error, _req, res, _next) => {
+ console.error(error)
+ res.status(502).json({ error: 'upstream_failed' })
+})
+
+app.listen(port, '127.0.0.1', () => {
+ console.log(`Backend on http://127.0.0.1:${port}, for ${issuer}`)
+})
diff --git a/examples/native-desktop/e2e/keychain.mjs b/examples/native-desktop/e2e/keychain.mjs
new file mode 100644
index 0000000..2ec56e7
--- /dev/null
+++ b/examples/native-desktop/e2e/keychain.mjs
@@ -0,0 +1,12 @@
+// Reads or deletes the app's keychain entry from outside the app. The e2e runs this with the
+// Electron binary, because macOS asks the user before another program reads an item it created.
+import { AsyncEntry } from '@napi-rs/keyring'
+
+const [operation, service, account] = process.argv.slice(2)
+const entry = new AsyncEntry(service, account, { linux: { store: 'secret-service' } })
+if (operation === 'read') {
+ process.stdout.write(JSON.stringify({ value: await entry.getPassword() }))
+} else if (operation === 'delete') {
+ await entry.deleteCredential().catch(() => {})
+}
+process.exit(0)
diff --git a/examples/native-desktop/e2e/run.mjs b/examples/native-desktop/e2e/run.mjs
new file mode 100644
index 0000000..3b0e34c
--- /dev/null
+++ b/examples/native-desktop/e2e/run.mjs
@@ -0,0 +1,709 @@
+// Runs the example end to end against a local recipe: the real Electron app, its real keychain
+// entry, the backend, and the hosted page in Chromium with a virtual authenticator. Chromium stands
+// in for the user's default browser, so shell.openExternal is replaced here and only here, to hand
+// the URL to it. Needs the recipe up and scripts/enable-notices.mjs already run.
+// RECIPE_DIR=/path/to/local node e2e/run.mjs
+import { spawn, spawnSync } from 'node:child_process'
+import { createHash, X509Certificate } from 'node:crypto'
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { createRequire } from 'node:module'
+import { networkInterfaces, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { connect } from 'node:tls'
+import { fileURLToPath } from 'node:url'
+
+import * as oidc from 'openid-client'
+import { _electron, chromium } from 'playwright'
+
+import { readRecipe } from '../scripts/recipe.mjs'
+
+const STEP_UP_MAX_AGE = 30
+const root = fileURLToPath(new URL('..', import.meta.url))
+const shots = process.env.SCREENSHOT_DIR ?? join(root, 'e2e', 'screenshots')
+mkdirSync(shots, { recursive: true })
+process.env.BACKEND_PORT ??= '8797'
+
+if (!process.env.RECIPE_DIR) {
+ console.error('Set RECIPE_DIR to the recipe local directory.')
+ process.exit(1)
+}
+const {
+ recipe,
+ app: appEnv,
+ backend: backendEnv,
+ mailboxUrl,
+ appName,
+} = readRecipe(process.env.RECIPE_DIR)
+const caFile = appEnv.NODE_EXTRA_CA_CERTS
+const issuerUrl = new URL(recipe.issuer)
+const email = `desk+${Date.now()}@${appName}.localhost`
+const electronBinary = createRequire(import.meta.url)('electron')
+const service = 'com.example.desknotes'
+const account = `${recipe.issuer} ${recipe.nativeClientId}`
+const userData = mkdtempSync(join(tmpdir(), 'desknotes-e2e-'))
+
+// The Node side of this script talks to the local CA and the issuer too.
+if (process.env.NODE_EXTRA_CA_CERTS !== caFile) {
+ const { status } = spawnSync(process.execPath, process.argv.slice(1), {
+ stdio: 'inherit',
+ env: { ...process.env, NODE_EXTRA_CA_CERTS: caFile },
+ })
+ process.exit(status ?? 1)
+}
+
+const failures = []
+function check(label, ok, detail) {
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${!ok && detail ? `: ${detail}` : ''}`)
+ if (!ok) failures.push(label)
+}
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+async function until(what, read, timeout = 30_000) {
+ const deadline = Date.now() + timeout
+ for (;;) {
+ const value = await read()
+ if (value) return value
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`)
+ await sleep(100)
+ }
+}
+
+function keychain(operation) {
+ const { stdout, status, stderr } = spawnSync(
+ electronBinary,
+ [join(root, 'e2e', 'keychain.mjs'), operation, service, account],
+ { cwd: root, encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } },
+ )
+ if (status !== 0) throw new Error(`keychain ${operation}: ${stderr}`)
+ return operation === 'read' ? JSON.parse(stdout).value : undefined
+}
+
+function filesContaining(dir, needle) {
+ const hits = []
+ for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
+ if (!entry.isFile()) continue
+ const path = join(entry.parentPath, entry.name)
+ try {
+ if (readFileSync(path).includes(needle)) hits.push(path)
+ } catch {
+ // Chromium holds some of its files locked.
+ }
+ }
+ return hits
+}
+
+// Chromium takes no CA file. It accepts the served certificate by key hash, after that certificate
+// is checked against the local CA here. WebAuthn refuses a page with a certificate error, so
+// ignoring errors altogether would not do.
+const servedKeyHash = () =>
+ new Promise((resolve, reject) => {
+ const socket = connect(
+ {
+ host: '127.0.0.1',
+ port: Number(issuerUrl.port),
+ servername: issuerUrl.hostname,
+ ca: readFileSync(caFile),
+ },
+ () => {
+ const der = new X509Certificate(socket.getPeerCertificate().raw).publicKey.export({
+ type: 'spki',
+ format: 'der',
+ })
+ socket.end()
+ resolve(createHash('sha256').update(der).digest('base64'))
+ },
+ )
+ socket.on('error', reject)
+ })
+
+const backend = spawn(process.execPath, [join(root, 'backend', 'server.mjs')], {
+ env: { ...process.env, ...backendEnv, STEP_UP_MAX_AGE: String(STEP_UP_MAX_AGE) },
+ stdio: ['ignore', 'inherit', 'inherit'],
+})
+const backendCall = (method, path, token) =>
+ fetch(new URL(path, appEnv.BACKEND_URL), {
+ method,
+ headers: token ? { authorization: `Bearer ${token}` } : {},
+ })
+
+const browser = await chromium.launch({
+ args: [`--ignore-certificate-errors-spki-list=${await servedKeyHash()}`],
+})
+const context = await browser.newContext()
+const page = await context.newPage()
+const cdp = await context.newCDPSession(page)
+await cdp.send('WebAuthn.enable')
+await cdp.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true,
+ },
+})
+
+let electronApp
+let window
+
+async function launchApp() {
+ electronApp = await _electron.launch({
+ executablePath: electronBinary,
+ args: [root, `--user-data-dir=${userData}`],
+ cwd: root,
+ env: { ...process.env, ...appEnv },
+ })
+ // Before the app does anything over the network: the stand-in for the default browser, and a
+ // record of what the main process sends and receives.
+ await electronApp.evaluate(({ shell }) => {
+ globalThis.opened = []
+ shell.openExternal = async (url) => {
+ globalThis.opened.push(url)
+ }
+ globalThis.traffic = []
+ const realFetch = globalThis.fetch
+ globalThis.fetch = async (input, init = {}) => {
+ const request = new Request(input, init)
+ const body = init.body ? String(init.body) : ''
+ const response = await realFetch(input, init)
+ globalThis.traffic.push({
+ url: request.url,
+ method: request.method,
+ body,
+ authorization: request.headers.get('authorization'),
+ status: response.status,
+ challenge: response.headers.get('www-authenticate'),
+ })
+ return response
+ }
+ })
+ window = await electronApp.firstWindow()
+ await window
+ .locator('#who')
+ .filter({ hasText: /Signed/ })
+ .waitFor()
+ await electronApp.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].hide())
+}
+
+const opened = () => electronApp.evaluate(() => globalThis.opened)
+const traffic = () => electronApp.evaluate(() => globalThis.traffic)
+const clearOpened = () => electronApp.evaluate(() => void (globalThis.opened = []))
+
+// The window is hidden so a test run does not take over the screen, and a hidden window paints
+// nothing, so the buttons are clicked through the DOM and not with Playwright's actionability checks.
+async function press(name) {
+ await window.locator('#result').evaluate((node) => (node.textContent = ''))
+ await window.getByRole('button', { name }).evaluate((button) => button.click())
+}
+const result = async () => {
+ const text = await window.locator('#result').textContent()
+ return text ? JSON.parse(text) : undefined
+}
+const finished = () => until('the app to finish', result, 60_000)
+const whoIs = () => window.locator('#who').textContent()
+const shownClaims = async () => JSON.parse((await window.locator('#claims').textContent()) || '{}')
+
+async function nextCode(before) {
+ return until(`a code in the mailbox for ${email}`, async () => {
+ const all = await (await fetch(`${mailboxUrl}/codes/${encodeURIComponent(email)}`)).json()
+ return all.length > before ? all.at(-1) : undefined
+ })
+}
+
+const atLoopback = () => page.url().startsWith('http://127.0.0.1')
+
+async function register() {
+ await page
+ .getByLabel(/email address/i)
+ .first()
+ .fill(email)
+ await page.getByRole('button', { name: /^register$/i }).click()
+ await page.waitForURL(/verify-email-otp/)
+ const code = await nextCode(0)
+ const inputs = page.locator('input')
+ if ((await inputs.count()) >= 6) {
+ for (let i = 0; i < 6; i += 1) await inputs.nth(i).fill(code[i])
+ } else {
+ await inputs.first().fill(code)
+ }
+ const verify = page.getByRole('button', { name: /verify|continue|submit/i })
+ if (await verify.count()) {
+ await verify
+ .first()
+ .click()
+ .catch(() => {})
+ }
+ await page.waitForURL(/register-passkey/, { timeout: 20_000 })
+ await page.screenshot({ path: join(shots, 'register-passkey.png') })
+ await page
+ .getByRole('button', { name: /register|add|create|continue/i })
+ .first()
+ .click()
+ const modalContinue = page.getByRole('button', { name: /^continue$/i })
+ await modalContinue
+ .waitFor({ timeout: 5000 })
+ .then(() => modalContinue.click())
+ .catch(() => {})
+ await page.waitForURL(/recovery-codes|127\.0\.0\.1/, { timeout: 20_000 })
+ if (page.url().includes('recovery-codes')) {
+ await page.getByLabel(/i have saved/i).check()
+ await page.getByRole('button', { name: /^continue$/i }).click()
+ }
+}
+
+async function signInWithPasskey() {
+ const toSignIn = page.getByRole('button', { name: /already have an account/i })
+ if (await toSignIn.count()) await toSignIn.click()
+ await page.getByLabel(/email address \/ phone number/i).fill(email)
+ await page.getByRole('button', { name: /^login$/i }).click()
+}
+
+// Presses what the hosted page offers (a passkey, then consent) until the browser is back on the
+// loopback redirect.
+async function finishInteraction(label) {
+ const deadline = Date.now() + 45_000
+ let shot = false
+ while (Date.now() < deadline && !atLoopback()) {
+ const offered = page
+ .getByRole('button', { name: /passkey|^(allow|continue|accept|confirm|verify)/i })
+ .first()
+ if (await offered.isEnabled({ timeout: 300 }).catch(() => false)) {
+ if (!shot) await page.screenshot({ path: join(shots, `${label}-interaction.png`) })
+ shot = true
+ await offered.click({ timeout: 2000 }).catch(() => {})
+ }
+ await sleep(250)
+ }
+ if (!atLoopback()) throw new Error(`${label}: the browser never reached the loopback redirect`)
+}
+
+async function driveBrowser(label, act) {
+ const url = await until(`${label}: the app to open the browser`, async () =>
+ (await opened()).at(-1),
+ )
+ await clearOpened()
+ await page.goto(url)
+ await act?.()
+ await finishInteraction(label)
+ return new URL(url)
+}
+
+let exitCode = 1
+try {
+ const publicConfig = await oidc.discovery(
+ issuerUrl,
+ recipe.nativeClientId,
+ undefined,
+ oidc.None(),
+ )
+ await until('the backend', () =>
+ backendCall('GET', '/health').then(
+ (r) => r.ok,
+ () => false,
+ ),
+ )
+ keychain('delete')
+
+ console.log('\nThe backend, before any sign-in')
+ const anonymous = await backendCall('GET', '/notes')
+ check(
+ 'no token: 401 missing_token',
+ anonymous.status === 401 && (await anonymous.json()).error === 'missing_token',
+ )
+ const garbage = await backendCall('GET', '/notes', 'not.a.token')
+ check(
+ 'a bad token: 401 invalid_token',
+ garbage.status === 401 && (await garbage.json()).error === 'invalid_token',
+ )
+
+ await launchApp()
+ check('the app starts signed out', (await whoIs()) === 'Signed out')
+ check('the keychain starts empty', keychain('read') === null)
+
+ console.log('\nSign-in: the system browser, PKCE and a loopback redirect')
+ await press(/^Sign in with your browser/)
+ const firstUrl = new URL(await until('the browser', async () => (await opened()).at(-1)))
+ const firstRedirect = new URL(firstUrl.searchParams.get('redirect_uri'))
+ check('the authorization URL is on the issuer', firstUrl.origin === recipe.issuer)
+ check(
+ 'PKCE with S256',
+ firstUrl.searchParams.get('code_challenge_method') === 'S256' &&
+ Boolean(firstUrl.searchParams.get('code_challenge')),
+ )
+ check('the request carries no secret', !firstUrl.searchParams.has('client_secret'))
+ check(
+ 'the redirect is 127.0.0.1 on an OS-picked port',
+ firstRedirect.hostname === '127.0.0.1' && Number(firstRedirect.port) >= 1024,
+ firstRedirect.href,
+ )
+ check(
+ 'the token is requested for the API',
+ firstUrl.searchParams.get('resource') === recipe.resource,
+ )
+
+ const appSource = readdirSync(join(root, 'app'))
+ .map((file) => readFileSync(join(root, 'app', file), 'utf8'))
+ .join('\n')
+ check(
+ 'the app source has no secret, backend key or auth API setting',
+ !/client_secret|BACKEND_KEY|BACKEND_CLIENT|SEAMLESS_API_URL|
+ BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
+ )
+ check(
+ 'the app never loads the sign-in page itself',
+ windows.length === 1 && windows[0].startsWith('file:'),
+ windows.join(),
+ )
+ const hasWebview = await window.locator('webview, iframe').count()
+ check('the app has no webview or frame', hasWebview === 0)
+
+ const forged = await fetch(`${firstRedirect.href}?code=forged&state=forged`)
+ check('the listener refuses a redirect with the wrong state', forged.status === 400)
+ const other = await fetch(`${firstRedirect.origin}/other`)
+ check('the listener answers only its path', other.status === 404)
+ const lan = Object.values(networkInterfaces())
+ .flat()
+ .find((address) => address.family === 'IPv4' && !address.internal)
+ if (lan) {
+ const reached = await fetch(`http://${lan.address}:${firstRedirect.port}/callback`).then(
+ () => true,
+ () => false,
+ )
+ check('the listener is not reachable off loopback', !reached)
+ }
+
+ await driveBrowser('register', async () => {
+ await page.waitForURL((url) => url.pathname === '/login', { timeout: 15_000 })
+ await page.getByRole('heading', { level: 2 }).waitFor({ timeout: 10_000 })
+ check('the hosted page is at the issuer', page.url().startsWith(recipe.issuer))
+ await page.screenshot({ path: join(shots, 'hosted-page.png') })
+ await register()
+ })
+ const signedIn = await finished()
+ check('the sign-in finished in the app', signedIn.ok, JSON.stringify(signedIn))
+ check('the app shows signed in', (await whoIs()) === 'Signed in')
+ await page.screenshot({ path: join(shots, 'back-at-app.png') })
+
+ const tokenRequest = (await traffic()).find((call) => call.body.includes('authorization_code'))
+ const tokenParams = new URLSearchParams(tokenRequest.body)
+ check(
+ 'the code exchange sends the verifier and client_id, and no secret',
+ tokenParams.has('code_verifier') &&
+ tokenParams.get('client_id') === recipe.nativeClientId &&
+ !tokenParams.has('client_secret') &&
+ !tokenRequest.authorization,
+ )
+ const refreshAfterSignIn = keychain('read')
+ check('the refresh token is in the OS keychain', Boolean(refreshAfterSignIn))
+ check(
+ 'the refresh token is in no file the app wrote',
+ filesContaining(userData, refreshAfterSignIn).length === 0,
+ filesContaining(userData, refreshAfterSignIn).join(),
+ )
+ const firstClaims = await shownClaims()
+ check('the token is for the API', firstClaims.aud === recipe.resource, firstClaims.aud)
+ check('registering used an email code, so no acr', firstClaims.acr === undefined, firstClaims.acr)
+ console.log(` claims: ${JSON.stringify(firstClaims)}`)
+ const lastBearer = async () =>
+ (await traffic())
+ .filter((call) => call.authorization)
+ .at(-1)
+ ?.authorization.slice(7)
+
+ console.log('\nA route that needs only a scope')
+ await press(/^List notes/)
+ const listed = await finished()
+ check('GET /notes answers 200', listed.result?.status === 200, JSON.stringify(listed))
+ check('with no step-up', listed.result?.stepUp === undefined)
+
+ console.log('\nA route that needs a recent passkey')
+ const beforeStepUp = await lastBearer()
+ const refused = await backendCall('POST', '/notes/export', beforeStepUp)
+ const refusedBody = await refused.json()
+ const challengeHeader = refused.headers.get('www-authenticate')
+ console.log(` challenge: ${challengeHeader}`)
+ check(
+ '401 insufficient_user_authentication',
+ refused.status === 401 && refusedBody.error === 'insufficient_user_authentication',
+ )
+ check(
+ 'the challenge names phr and max_age',
+ /error="insufficient_user_authentication"/.test(challengeHeader) &&
+ /acr_values="phr"/.test(challengeHeader) &&
+ new RegExp(`max_age="?${STEP_UP_MAX_AGE}`).test(challengeHeader),
+ )
+
+ await clearOpened()
+ await press(/^Export notes/)
+ const stepUrl = await driveBrowser('step-up')
+ check('the app asked for acr_values=phr', stepUrl.searchParams.get('acr_values') === 'phr')
+ check(
+ 'the app asked for max_age',
+ stepUrl.searchParams.get('max_age') === String(STEP_UP_MAX_AGE),
+ )
+ const exported = await finished()
+ check(
+ 'the retried POST /notes/export answers 200',
+ exported.result?.status === 200,
+ JSON.stringify(exported),
+ )
+ const stepped = exported.result?.body?.signedInWith ?? {}
+ console.log(` signed in with: ${JSON.stringify(stepped)}`)
+ check('the token states phr or phrh', ['phr', 'phrh'].includes(stepped.acr), stepped.acr)
+ check('the token states a passkey in amr', /swk|hwk/.test(stepped.amr?.join() ?? ''), stepped.amr)
+
+ const refreshAfterStepUp = keychain('read')
+ check('the step-up replaced the stored refresh token', refreshAfterStepUp !== refreshAfterSignIn)
+ const superseded = await oidc
+ .refreshTokenGrant(publicConfig, refreshAfterSignIn, { resource: recipe.resource })
+ .then(
+ () => null,
+ (error) => error.error,
+ )
+ check('the app revoked the refresh token it replaced', superseded === 'invalid_grant', superseded)
+
+ console.log('\nA notice through the client link')
+ await press(/^Email me a notice/)
+ const notice = await finished()
+ check('the backend sent the notice', notice.result?.status === 200, JSON.stringify(notice))
+ console.log(` answer: ${JSON.stringify(notice.result?.body)}`)
+ const mail = await until('the notice in the mailbox', async () => {
+ const text = await (await fetch(`${mailboxUrl}/`)).text()
+ return text.split('\n').find((line) => line.includes(email) && line.includes('Sent by'))
+ })
+ check(
+ 'the email names the desktop app, not the backend',
+ mail.includes(`Sent by ${appName} desktop app`),
+ mail,
+ )
+ const direct = await fetch(`${recipe.apiUrl}/applications/account-notices`, {
+ method: 'POST',
+ headers: {
+ authorization: `Bearer ${await lastBearer()}`,
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({ userId: firstClaims.sub, subject: 'x', text: 'x' }),
+ })
+ check('a user token cannot send a notice', direct.status === 401, String(direct.status))
+
+ console.log('\nWho the backend serves, and what a public client cannot do')
+ const jwk = JSON.parse(readFileSync(backendEnv.BACKEND_KEY_FILE, 'utf8'))
+ const backendConfig = await oidc.discovery(
+ issuerUrl,
+ recipe.backendClientId,
+ undefined,
+ oidc.PrivateKeyJwt({
+ key: await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ ),
+ kid: jwk.kid,
+ }),
+ )
+ const machine = await oidc.clientCredentialsGrant(backendConfig, {
+ resource: recipe.resource,
+ scope: 'notes:read',
+ })
+ const machineAnswer = await backendCall('GET', '/notes', machine.access_token)
+ check(
+ "another client's valid token for this resource: 403 client_not_allowed",
+ machineAnswer.status === 403 && (await machineAnswer.json()).error === 'client_not_allowed',
+ String(machineAnswer.status),
+ )
+ const publicMachine = await oidc
+ .clientCredentialsGrant(publicConfig, { resource: recipe.resource })
+ .then(
+ () => null,
+ (error) => error.error,
+ )
+ check('the public client cannot use client_credentials', Boolean(publicMachine), publicMachine)
+
+ console.log('\nRefresh')
+ const beforeRefresh = keychain('read')
+ await press(/^Refresh tokens/)
+ const refreshed = await finished()
+ check('the refresh succeeded', refreshed.ok, JSON.stringify(refreshed))
+ check('the refresh token rotated in the keychain', keychain('read') !== beforeRefresh)
+ const afterRefresh = await shownClaims()
+ check('acr survives a refresh', afterRefresh.acr === stepped.acr, afterRefresh.acr)
+ check('auth_time survives a refresh', afterRefresh.authTime === stepped.authTime)
+
+ console.log(`\nFreshness: ${STEP_UP_MAX_AGE} seconds later the same route challenges again`)
+ await sleep(Math.max(0, (afterRefresh.authTime + STEP_UP_MAX_AGE + 2) * 1000 - Date.now()))
+ const stale = await backendCall('POST', '/notes/export', await lastBearer())
+ check('401 once auth_time is older than max_age', stale.status === 401, String(stale.status))
+ await clearOpened()
+ await press(/^Export notes/)
+ await driveBrowser('step-up-again')
+ const exportedAgain = await finished()
+ check(
+ 'a new passkey sign-in satisfies it',
+ exportedAgain.result?.status === 200,
+ JSON.stringify(exportedAgain),
+ )
+ check(
+ 'with a newer auth_time',
+ exportedAgain.result?.body?.signedInWith?.authTime > stepped.authTime,
+ )
+
+ console.log('\nA new launch of the app')
+ const beforeRelaunch = keychain('read')
+ await electronApp.close()
+ await launchApp()
+ await until('the session restored', async () => (await keychain('read')) !== beforeRelaunch)
+ await press(/^List notes/)
+ const relisted = await finished()
+ check(
+ 'the keychain signs the app back in',
+ relisted.result?.status === 200,
+ JSON.stringify(relisted),
+ )
+ check('with no browser', (await opened()).length === 0)
+
+ console.log('\nSign out with revocation')
+ const lastRefresh = keychain('read')
+ const lastAccess = await lastBearer()
+ await press(/^Sign out$/)
+ const signedOut = await finished()
+ check(
+ 'the app reports the token revoked',
+ signedOut.result?.revoked === true,
+ JSON.stringify(signedOut),
+ )
+ check('the keychain entry is gone', keychain('read') === null)
+ check('the app shows signed out', (await whoIs()) === 'Signed out')
+ check(
+ 'the revocation went to the revocation endpoint',
+ (await traffic()).some((call) => call.url.endsWith('/token/revocation') && call.status === 200),
+ )
+ const replay = await oidc
+ .refreshTokenGrant(publicConfig, lastRefresh, { resource: recipe.resource })
+ .then(
+ () => null,
+ (error) => error.error,
+ )
+ check('the revoked refresh token is refused', replay === 'invalid_grant', replay)
+ const afterRevoke = await backendCall('GET', '/notes', lastAccess)
+ console.log(
+ ` the access token issued before sign-out answers ${afterRevoke.status} at the backend until it expires`,
+ )
+ check('a verify-only backend does not see revocation', afterRevoke.status === 200)
+
+ console.log('\nThe Seamless session outlives an app sign-out')
+ await clearOpened()
+ await press(/^Sign in with your browser/)
+ const secondUrl = await driveBrowser('second-sign-in')
+ await finished()
+ check('signed in again with no new sign-in ceremony', (await whoIs()) === 'Signed in')
+ check(
+ 'on another port',
+ new URL(secondUrl.searchParams.get('redirect_uri')).port !== firstRedirect.port,
+ )
+
+ console.log('\nSign out of Seamless too')
+ await clearOpened()
+ await press(/^Sign out of Seamless too/)
+ await finished()
+ const endUrl = new URL(await until('the end session URL', async () => (await opened()).at(-1)))
+ check(
+ 'the app opens the end session URL in the browser',
+ endUrl.pathname.endsWith('/session/end'),
+ )
+ check('with an id_token_hint', endUrl.searchParams.has('id_token_hint'))
+ await page.goto(endUrl.href)
+ await page.screenshot({ path: join(shots, 'end-session.png') })
+ await page
+ .getByRole('button', { name: /yes|sign out|log ?out|confirm/i })
+ .first()
+ .click()
+ await page.waitForLoadState('networkidle')
+ await page.screenshot({ path: join(shots, 'ended.png') })
+
+ console.log('\nA passkey sign-in from nothing satisfies the route with no challenge')
+ await clearOpened()
+ await press(/^Sign in with your browser/)
+ await driveBrowser('passkey', async () => {
+ await page.waitForURL((url) => url.pathname === '/login', { timeout: 15_000 })
+ await page.getByRole('heading', { level: 2 }).waitFor({ timeout: 10_000 })
+ await signInWithPasskey()
+ })
+ await finished()
+ const passkeyClaims = await shownClaims()
+ check('same account', passkeyClaims.sub === firstClaims.sub)
+ check('acr is phr or phrh', ['phr', 'phrh'].includes(passkeyClaims.acr), passkeyClaims.acr)
+ console.log(` claims: ${JSON.stringify(passkeyClaims)}`)
+ await clearOpened()
+ await press(/^Export notes/)
+ const noChallenge = await finished()
+ check(
+ 'POST /notes/export answers 200',
+ noChallenge.result?.status === 200,
+ JSON.stringify(noChallenge),
+ )
+ check('the app did not need the browser', (await opened()).length === 0)
+
+ console.log('\nA post-logout redirect to loopback, on any port of the registered URI')
+ await clearOpened()
+ await press(/^Sign out of Seamless too/)
+ await finished()
+ const lastEndUrl = new URL(
+ await until('the end session URL', async () => (await opened()).at(-1)),
+ )
+ const signedOutListener = createServer((_req, res) => res.end('Signed out'))
+ await new Promise((resolve) => signedOutListener.listen(0, '127.0.0.1', resolve))
+ const { port: signedOutPort } = signedOutListener.address()
+ const logoutTo = (path) => {
+ const url = new URL(lastEndUrl)
+ url.searchParams.set('post_logout_redirect_uri', `http://127.0.0.1:${signedOutPort}${path}`)
+ return url.href
+ }
+ const wrongPath = await page.goto(logoutTo('/other'))
+ check('another path is refused', wrongPath.status() >= 400, String(wrongPath.status()))
+ await page.goto(logoutTo('/signed-out'))
+ await page
+ .getByRole('button', { name: /yes|sign out|log ?out|confirm/i })
+ .first()
+ .click()
+ await page.waitForURL(/127\.0\.0\.1/, { timeout: 15_000 })
+ check(
+ 'the registered path is accepted on a port it did not name',
+ page.url().startsWith(`http://127.0.0.1:${signedOutPort}/signed-out`),
+ page.url(),
+ )
+ signedOutListener.close()
+
+ exitCode = failures.length ? 1 : 0
+ console.log(failures.length ? `\n${failures.length} failed` : '\nAll checks passed')
+} catch (error) {
+ console.log(`\nStopped: ${error.message.split('\n')[0]}`)
+ console.log(` browser at ${page.url()}`)
+ await page.screenshot({ path: join(shots, 'failure.png') }).catch(() => {})
+ console.log(
+ await page
+ .locator('body')
+ .ariaSnapshot()
+ .catch(() => ''),
+ )
+ if (window) {
+ console.log(
+ ` app result: ${await window
+ .locator('#result')
+ .textContent()
+ .catch(() => '')}`,
+ )
+ }
+} finally {
+ await electronApp?.close().catch(() => {})
+ await browser.close()
+ backend.kill()
+ keychain('delete')
+ rmSync(userData, { recursive: true, force: true })
+}
+process.exit(exitCode)
diff --git a/examples/native-desktop/e2e/with-keyring.sh b/examples/native-desktop/e2e/with-keyring.sh
new file mode 100755
index 0000000..bc09c8d
--- /dev/null
+++ b/examples/native-desktop/e2e/with-keyring.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Linux with no desktop session: starts a Secret Service for the command, with a keyring unlocked by
+# a throwaway password, inside the D-Bus session dbus-run-session gave it.
+set -euo pipefail
+
+eval "$(printf 'not-a-secret\n' | gnome-keyring-daemon --unlock --components=secrets)"
+export GNOME_KEYRING_CONTROL
+exec "$@"
diff --git a/examples/native-desktop/package-lock.json b/examples/native-desktop/package-lock.json
new file mode 100644
index 0000000..d62bf25
--- /dev/null
+++ b/examples/native-desktop/package-lock.json
@@ -0,0 +1,1647 @@
+{
+ "name": "seamless-native-desktop-example",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "seamless-native-desktop-example",
+ "version": "0.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/keyring": "2.1.1",
+ "@seamless-auth/express": "0.24.0",
+ "express": "5.2.1",
+ "openid-client": "6.8.9"
+ },
+ "devDependencies": {
+ "electron": "44.9.2",
+ "playwright": "1.64.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@electron-internal/extract-zip": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.8.tgz",
+ "integrity": "sha512-8iajeLFbzNwM0m3AsQZSZgYREWglAImyGvvFSWU34FgMW8sHlzdwj9QKvv9dgN7jMP591EFUF/sLOC3Jl9YIjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
+ "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^3.0.0",
+ "graceful-fs": "^4.2.11",
+ "progress": "^2.0.3",
+ "semver": "^7.6.3",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "optionalDependencies": {
+ "undici": "^7.24.4"
+ }
+ },
+ "node_modules/@napi-rs/keyring": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.1.tgz",
+ "integrity": "sha512-7HUq8FHgW/O4s8ESvkxngTC9tOR/IyktdR0ral2VD0C8HDZk86FwPAH4qZyerKwS1DhJ6AhoebIsxcyw+Lbt1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/keyring-darwin-arm64": "2.1.1",
+ "@napi-rs/keyring-darwin-x64": "2.1.1",
+ "@napi-rs/keyring-freebsd-x64": "2.1.1",
+ "@napi-rs/keyring-linux-arm-gnueabihf": "2.1.1",
+ "@napi-rs/keyring-linux-arm64-gnu": "2.1.1",
+ "@napi-rs/keyring-linux-arm64-musl": "2.1.1",
+ "@napi-rs/keyring-linux-riscv64-gnu": "2.1.1",
+ "@napi-rs/keyring-linux-x64-gnu": "2.1.1",
+ "@napi-rs/keyring-linux-x64-musl": "2.1.1",
+ "@napi-rs/keyring-win32-arm64-msvc": "2.1.1",
+ "@napi-rs/keyring-win32-ia32-msvc": "2.1.1",
+ "@napi-rs/keyring-win32-x64-msvc": "2.1.1"
+ }
+ },
+ "node_modules/@napi-rs/keyring-darwin-arm64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.1.1.tgz",
+ "integrity": "sha512-H6uxAztj6GSDwnh/1JDHCtAqWvBTr+0+FGwOOw+rY568z7vFOqrJTTboePFuAnJSO7kNvq+yuyXJ9B5Ueco4yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-darwin-x64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.1.1.tgz",
+ "integrity": "sha512-RcHBNnOIzqliNU8Z0jc71bPs/PRn4IIO02XE6DV3HxVFAHo4CooNXDySCL+jbHhObLlSy9yynRn+wq5k+IAmRw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-freebsd-x64": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.1.1.tgz",
+ "integrity": "sha512-IrM8IKBZKQzETI1BlvSpT6lRzC9eTIQ5TgUwcbQSiQTuXTEXAAHHvBYkoHicN2b2uqgAfO3dwN382CGRw0Ownw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.1.1.tgz",
+ "integrity": "sha512-KMld1c9TRucQYV/qasA5jS/VZWCpxMOeHflrciOAsfhiVQ8VfA77grlhGD0kiWtz5e+2M6kYLH9iFmQhPROkGw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.1.1.tgz",
+ "integrity": "sha512-AcMk+ZDP8H20Ml5vaTuSh/Ibh5vU4lrAavS2tjoJ7EJJBLDYssONINWrvVgy43SfzntgQVDBcU5GlMI+z5IMFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-arm64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.1.1.tgz",
+ "integrity": "sha512-wUHecqT9SMUaQCsJO6vtFEsMcfskspXf6+J0AtKsnK8/GnAAb4hf2FHtwp2ZUOecOk1kTW3LPhU5LwTMJ1LLeA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.1.1.tgz",
+ "integrity": "sha512-hrfvuY0zxrpJOa4k/LzWkrB/R/nXnniz/I2V39X5pWgxKJ1iZBQI03/wT8OI7BwSv9czSh5LLeX/o5D5ywRHBg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-x64-gnu": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.1.1.tgz",
+ "integrity": "sha512-NHfMyYyu6laqRBzgfsB0zN12l+z5gz6xQqmLDT8ijevMMOc9ICcVo2GUJCPSX5WvRBgzui91D4R4QG0tahMurw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-linux-x64-musl": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.1.1.tgz",
+ "integrity": "sha512-5E2pO2zqOv+HJr2YorAUYmqItv8ts2oa+BccxZ07/I7x3XvxqwZGtRbkp4wM8psv7phhIfgy+0kZ/ItBsGiDhg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-arm64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.1.1.tgz",
+ "integrity": "sha512-gn+Xg3oZmyC/pMIU0HTCyrIsncn2/u47H3JRQ8WKfPgS0KDCytcf2kuCCSShtNETSE5V8Zc18ymgIW5UrOQ5OA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-ia32-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.1.1.tgz",
+ "integrity": "sha512-DeMQnalb+ueYjskBBUjwv0b9xuN9vhs/vi2H4q/crERfDWgmqu77rGZOW4Bi9kDc7Za3t29yeZctEBZzjAtXuA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/keyring-win32-x64-msvc": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.1.1.tgz",
+ "integrity": "sha512-DjRshMlsMdEyZTNpqfQYj60kDrCZLkl4y80U3w5WJQIj7ypToj02+A/hfJw/g68wzbSZ6uXhklyITQ1DABZAUg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@seamless-auth/core": {
+ "version": "0.24.0",
+ "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.24.0.tgz",
+ "integrity": "sha512-9IB611q2acnhfOErVnyeMaiK4u5MQB8oZdqViyuN+xG6a6kNhVvGIFNHIaSGQoLVrZB92S67LRNbxLPiiy21nQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@seamless-auth/types": "^0.35.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "jose": "^6.1.3",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@seamless-auth/express": {
+ "version": "0.24.0",
+ "resolved": "https://registry.npmjs.org/@seamless-auth/express/-/express-0.24.0.tgz",
+ "integrity": "sha512-GWuonxpoz7K4PyeBxO6ORrPVgj7kyBlh6x6vQbnmW2JMotS2VyBc/WBGqHQzJCIdsWW24XGIjJMAodKt35Dbqg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@seamless-auth/core": "^0.24.0",
+ "cookie-parser": "^1.4.6",
+ "jsonwebtoken": "^9.0.3"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@types/express": ">=5.0.0",
+ "express": ">=5.0.0"
+ }
+ },
+ "node_modules/@seamless-auth/types": {
+ "version": "0.35.0",
+ "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.35.0.tgz",
+ "integrity": "sha512-T/1E72d43M+Bzivgo9k8yxaSBRpaXO9XJYlsenCwYVPb71Qnis+w10nXS7X/Qh/zKp7otOjRys2T3jMgP1L6Eg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "zod": "^4.5.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
+ "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.6",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
+ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
+ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.1.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.16.1",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/electron": {
+ "version": "44.9.2",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.2.tgz",
+ "integrity": "sha512-mZ79mSsjLlgU91Uck3UhDcI0AU/draTkS+qsSnasZstNYitsdHr5XeIK3b6I6RkLqVe07QHeYVRZnQVTHL/9Lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron-internal/extract-zip": "^1.0.1",
+ "@electron/get": "^5.0.0",
+ "@types/node": "^24.9.0"
+ },
+ "bin": {
+ "electron": "cli.js",
+ "install-electron": "install.js"
+ },
+ "engines": {
+ "node": ">= 22.12.0"
+ }
+ },
+ "node_modules/electron/node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/electron/node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
+ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
+ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/jose": {
+ "version": "6.2.15",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
+ "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/negotiator/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/oauth4webapi": {
+ "version": "3.8.8",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
+ "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "6.8.9",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.9.tgz",
+ "integrity": "sha512-6mXMRNJxDOf6BflEWLXul1UwzLFz++9Ofz1PYwPwwsJsyXBsbfkO+Jo255SW1qnFNLYz4EJWHz42X4OJJrya/g==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.2.15",
+ "oauth4webapi": "^3.8.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.17.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
+ "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.30.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
+ "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/examples/native-desktop/package.json b/examples/native-desktop/package.json
new file mode 100644
index 0000000..01617a3
--- /dev/null
+++ b/examples/native-desktop/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "seamless-native-desktop-example",
+ "private": true,
+ "version": "0.0.0",
+ "description": "A desktop app and its backend on the Seamless Auth OpenID Provider: system browser sign-in, OS keychain, verify-only API, step-up and a client link notice.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "main": "app/main.mjs",
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "scripts": {
+ "app": "electron .",
+ "backend": "node backend/server.mjs",
+ "e2e": "node e2e/run.mjs"
+ },
+ "dependencies": {
+ "@napi-rs/keyring": "2.1.1",
+ "@seamless-auth/express": "0.24.0",
+ "express": "5.2.1",
+ "openid-client": "6.8.9"
+ },
+ "devDependencies": {
+ "electron": "44.9.2",
+ "playwright": "1.64.1"
+ }
+}
diff --git a/examples/native-desktop/scripts/enable-notices.mjs b/examples/native-desktop/scripts/enable-notices.mjs
new file mode 100644
index 0000000..f1be6c0
--- /dev/null
+++ b/examples/native-desktop/scripts/enable-notices.mjs
@@ -0,0 +1,90 @@
+// Turns account notices on for the recipe's backend client, the way an operator would: the owner
+// signs in with an email code, steps up with TOTP, and sets noticesEnabled through the admin API.
+// The recipe's seed does not do this yet, and its
+// three records and link are otherwise everything this example needs. Delete this file once it does.
+// node scripts/enable-notices.mjs
+import { createHmac } from 'node:crypto'
+import { readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { readRecipe } from './recipe.mjs'
+
+const [recipeDir] = process.argv.slice(2)
+if (!recipeDir) {
+ console.error('Usage: node scripts/enable-notices.mjs ')
+ process.exit(1)
+}
+const { generated, recipe, mailboxUrl } = readRecipe(recipeDir)
+const statePath = join(generated, 'seed-state.json')
+const state = JSON.parse(readFileSync(statePath, 'utf8'))
+const owner = recipe.mailboxOwner
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+
+async function must(method, path, { token, body } = {}) {
+ const res = await fetch(`${recipe.apiUrl}${path}`, {
+ method,
+ headers: {
+ 'content-type': 'application/json',
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+ const text = await res.text()
+ if (res.status >= 300) throw new Error(`${method} ${path}: ${res.status} ${text}`)
+ return text ? JSON.parse(text) : {}
+}
+
+const codes = async () => (await fetch(`${mailboxUrl}/codes/${encodeURIComponent(owner)}`)).json()
+
+function base32(secret) {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
+ let bits = ''
+ for (const c of secret.replace(/=+$/, '').toUpperCase()) {
+ bits += alphabet.indexOf(c).toString(2).padStart(5, '0')
+ }
+ const bytes = []
+ for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2))
+ return Buffer.from(bytes)
+}
+
+function totp(secret, step) {
+ const counter = Buffer.alloc(8)
+ counter.writeBigUInt64BE(BigInt(step))
+ const mac = createHmac('sha1', base32(secret)).update(counter).digest()
+ const offset = mac[mac.length - 1] & 0xf
+ return String((mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000).padStart(6, '0')
+}
+
+// A code is single use, and the seed may have spent this step's. The state file is the seed's, so
+// a later seed knows what was spent here too.
+async function freshTotp() {
+ const current = () => Math.floor(Date.now() / 30000)
+ if (state.lastTotpStep !== undefined && current() <= state.lastTotpStep) {
+ await sleep((state.lastTotpStep + 1) * 30000 - Date.now() + 500)
+ }
+ state.lastTotpStep = current()
+ writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 })
+ return totp(state.ownerTotpSecret, state.lastTotpStep)
+}
+
+const before = (await codes()).length
+const started = await must('POST', '/login', { body: { identifier: owner } })
+await must('GET', '/otp/generate-login-email-otp', { token: started.token })
+let code
+for (let i = 0; i < 40 && !code; i += 1) {
+ const all = await codes()
+ if (all.length > before) code = all.at(-1)
+ else await sleep(250)
+}
+if (!code) throw new Error(`no code reached the mailbox for ${owner}`)
+const { token } = await must('POST', '/otp/verify-login-email-otp', {
+ token: started.token,
+ body: { verificationToken: code },
+})
+await must('POST', '/totp/verify-mfa', { token, body: { code: await freshTotp() } })
+
+const { application } = await must('PATCH', `/admin/applications/${recipe.backendClientId}`, {
+ token,
+ body: { noticesEnabled: true },
+})
+console.log(`noticesEnabled is ${application.noticesEnabled} on ${application.name}`)
diff --git a/examples/native-desktop/scripts/recipe-env.mjs b/examples/native-desktop/scripts/recipe-env.mjs
new file mode 100644
index 0000000..32cc0b2
--- /dev/null
+++ b/examples/native-desktop/scripts/recipe-env.mjs
@@ -0,0 +1,13 @@
+// Prints one side's settings for a running local recipe, as shell exports:
+// eval "$(node scripts/recipe-env.mjs backend /path/to/seamless-auth-oidc-provider/local)"
+// eval "$(node scripts/recipe-env.mjs app /path/to/seamless-auth-oidc-provider/local)"
+import { readRecipe } from './recipe.mjs'
+
+const [side, recipeDir] = process.argv.slice(2)
+if (!['app', 'backend'].includes(side) || !recipeDir) {
+ console.error('Usage: node scripts/recipe-env.mjs ')
+ process.exit(1)
+}
+for (const [name, value] of Object.entries(readRecipe(recipeDir)[side])) {
+ console.log(`export ${name}='${value}'`)
+}
diff --git a/examples/native-desktop/scripts/recipe.mjs b/examples/native-desktop/scripts/recipe.mjs
new file mode 100644
index 0000000..f272585
--- /dev/null
+++ b/examples/native-desktop/scripts/recipe.mjs
@@ -0,0 +1,41 @@
+import { readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+
+/** What the example needs from a running local recipe, whose `local/` directory is `recipeDir`. */
+export function readRecipe(recipeDir) {
+ const dir = resolve(recipeDir)
+ const generated = join(dir, '.generated')
+ const settings = Object.fromEntries(
+ readFileSync(join(dir, 'settings.env'), 'utf8')
+ .split('\n')
+ .filter((line) => /^[A-Z_]+=/.test(line))
+ .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]),
+ )
+ const recipe = JSON.parse(readFileSync(join(generated, 'recipe.json'), 'utf8'))
+ const shared = {
+ SEAMLESS_ISSUER: recipe.issuer,
+ NATIVE_CLIENT_ID: recipe.nativeClientId,
+ API_RESOURCE: recipe.resource,
+ // Only for this local CA. A deployed issuer has a certificate the OS already trusts.
+ NODE_EXTRA_CA_CERTS: join(generated, 'ca.crt'),
+ }
+ return {
+ generated,
+ recipe,
+ mailboxUrl: `http://127.0.0.1:${settings.MAILBOX_PORT}`,
+ appName: settings.APP_NAME,
+ // What the desktop app gets is deliberately short: nothing secret, and no way to the auth API.
+ app: {
+ ...shared,
+ API_SCOPES: recipe.scopes.join(' '),
+ BACKEND_URL: `http://127.0.0.1:${process.env.BACKEND_PORT ?? 8787}`,
+ },
+ backend: {
+ ...shared,
+ BACKEND_PORT: process.env.BACKEND_PORT ?? '8787',
+ BACKEND_CLIENT_ID: recipe.backendClientId,
+ BACKEND_KEY_FILE: join(generated, 'backend-key.json'),
+ SEAMLESS_API_URL: recipe.apiUrl,
+ },
+ }
+}
diff --git a/src/content/docs/build/native-apps-oidc.mdx b/src/content/docs/build/native-apps-oidc.mdx
index b1f4928..c6c2770 100644
--- a/src/content/docs/build/native-apps-oidc.mdx
+++ b/src/content/docs/build/native-apps-oidc.mdx
@@ -1,6 +1,6 @@
---
title: 'Sign-In For Native, Desktop And CLI Apps'
-description: 'Sign users in to desktop apps, CLIs and other native apps through the Seamless Auth OpenID Provider the RFC 8252 way: a public client, the system browser, PKCE, loopback redirects on any port, IPv6 loopback and private-use schemes, and what OAuth 2.1 refuses.'
+description: 'Sign users in to desktop apps, CLIs and other native apps through the Seamless Auth OpenID Provider the RFC 8252 way (public client, system browser, PKCE, loopback or private-use redirects, keychain storage), and give a desktop app a backend with verify-only tokens, step-up and account notices, with a runnable Electron example.'
sidebar:
order: 14
---
@@ -18,8 +18,10 @@ Read [Use Seamless Auth As An OpenID Provider](/build/openid-provider) first for
the registry and the token model. This page covers what is different for native apps.
---
@@ -36,7 +38,7 @@ the registry and the token model. This page covers what is different for native
4. **Receive the code on a loopback redirect or a private-use scheme.** See the next section.
5. **Keep tokens in the OS credential store**: the macOS Keychain, Windows Credential Manager, or the
Secret Service API (GNOME Keyring, KWallet) on Linux. Not a plain file, not an environment
- variable.
+ variable. See [Keep Tokens In The OS Credential Store](#keep-tokens-in-the-os-credential-store).
---
@@ -150,23 +152,55 @@ to the browser every time the access token expires. See [Refresh tokens](/build/
---
+## Keep Tokens In The OS Credential Store
+
+- **The refresh token** goes in the OS credential store and nowhere else. If the store is locked or
+ absent, sign-in should fail and say so. Do not fall back to a file.
+- **The access token** stays in memory. It is short-lived and cheap to refresh.
+- **The ID token** stays in memory too, for `id_token_hint` on sign-out. A refresh returns a new one.
+- **One refresher at a time.** An app with several processes (a tray helper, a CLI, the main window)
+ refreshes through one of them, or behind a lock. In Electron, `app.requestSingleInstanceLock()`
+ and one in-flight refresh in the main process cover the app itself.
+- **Save before use.** Store a rotated refresh token before using the new access token, so a crash
+ cannot leave the app holding a spent one.
+- **The renderer never sees a token.** In Electron or Tauri, keep tokens in the native process and
+ give the page the results of calls, so script running in the page has nothing to take.
+
+A library that reaches the store directly is the simplest route. The example uses
+[`@napi-rs/keyring`](https://github.com/Brooooooklyn/keyring-node), with
+`{ linux: { store: 'secret-service' } }`: by default it falls back on Linux to the kernel keyring,
+which is memory only, so the user would be signed out at every reboot. Two things that look like the
+same choice are not, as checked on 2026-11-04:
+
+- [`keytar`](https://github.com/atom/node-keytar), the older Node library, is archived on GitHub.
+- Electron's [`safeStorage`](https://www.electronjs.org/docs/latest/api/safe-storage) encrypts data
+ for you to store yourself, and it is not a credential store. On Linux with no secret store, it
+ falls back to a hardcoded password, and `safeStorage.getSelectedStorageBackend()` returns
+ `basic_text`. If you use it anyway, check that and refuse.
+
+---
+
## Refresh, API Tokens And Sign-Out
- **Refresh.** Refresh tokens rotate on every use. Save the new one each time, before using the new
access token. A refresh token used twice revokes the grant and ends the user's Seamless session,
- so two processes of the same app must not refresh with one token at the same time.
+ so two processes of the same app must not refresh with one token at the same time. `acr`, `amr`
+ and `auth_time` on the new access token are the session's, so a step-up survives a refresh.
- **Tokens for your API.** Ask for a token for a named API with `resource=` on the
authorization request, using a resource the application is linked to. The access token is an RFC
9068 JWT whose `aud` is that API. See
[Access tokens for your API](/build/openid-provider#access-tokens-for-your-api).
- **Signing out of the app.** Revoke the refresh token at `/token/revocation` (a public client sends
its `client_id`), then delete the stored tokens. That ends this app's grant only. The user stays
- signed in to Seamless in the browser and in their other apps.
+ signed in to Seamless in the browser and in their other apps. An access token already issued
+ keeps working at a backend that verifies tokens locally until it expires, so keep access token
+ lifetimes short ([`accessTokenTtlSeconds`](/build/openid-provider#refresh-tokens)).
- **Signing out of Seamless.** To end the user's Seamless session too, revoke and delete the tokens,
then open the [end session URL](/build/openid-provider#rp-initiated-logout) in the system
browser, where the Seamless session cookie lives. The hosted page asks the user to confirm. To
bring the user back to the app, register a `post_logout_redirect_uri` and pass it with a `state`.
- It is matched exactly, with no loopback port exception, so a private-use scheme fits best.
+ A loopback URI registered for a native application matches on any port, as a redirect URI does,
+ so the listener can use a port the OS picks. Every other form is matched exactly.
```ts
const logoutUrl = oidc.buildEndSessionUrl(config, {
@@ -231,6 +265,194 @@ cannot reach the passkeys synced to the user's browser or phone in any case. Sen
the system browser keeps passkeys, saved sessions and any security keys working, and keeps the app
out of the credential path.
+The same page handles step-up. When a backend asks for a recent passkey, the app opens the system
+browser again, and the user confirms on the hosted page with the session they already have.
+
+---
+
+## An App With Its Own Backend
+
+Most desktop apps call their own API. The app holds the user's tokens and nothing else, and the
+backend trusts a call by verifying its token locally. The auth API stays private, so the app never
+reaches it.
+
+```text
+desktop app (public client) ──system browser──► login edge ──► auth API (private)
+ │ access token, aud = backend resource ▲ ▲
+ ▼ │ JWKS │ notices, client credentials
+backend API (resource server) ── verify-only adapter ─┘ │ through a client link
+ └──────────────────────────────────────────────────────────────┘
+```
+
+The design is [ADR 0002](https://github.com/fells-code/seamless-auth-api/blob/main/docs/adr/0002-native-and-desktop-apps.md).
+Nothing in it is specific to one product.
+
+### Versions
+
+The example was run against the versions in the last column. Anything earlier is unverified, even
+where a feature first shipped sooner.
+
+| Piece | Needs | Run against |
+| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| Public client, loopback redirect, PKCE, API tokens, refresh, revocation | The provider, from v0.21 | Edge image `seamless-auth-oidc-provider:v0.3.0` |
+| `acr`, `amr` and `auth_time` on an access token for a resource | `@seamless-auth/oidc-provider` 0.3.0 | Edge image `v0.3.0` |
+| Verify-only guards (`createResourceServer`) | `@seamless-auth/express` 0.23.0, with the same guards in the Fastify, Next.js, Go, Python and Rust adapters | `@seamless-auth/express` 0.24.0 |
+| Client links and `clientId` on account notices | **Seamless Auth API v0.23.0, which is not published.** API v0.22.0 has no link routes. | The API built from source at [`bcab4d1`](https://github.com/fells-code/seamless-auth-api/commit/bcab4d1b62ebb1f14e9b3eda8297ede01e38962f) |
+
+### Register Three Records
+
+| Record | Registry entry | Holds |
+| -------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------- |
+| Desktop app | Application, `clientType: public`, `tokenEndpointAuthMethod: none`, a loopback or private-use URI | Nothing secret |
+| Backend client | Application, `clientType: confidential`, `private_key_jwt`, `client_credentials`, notices turned on | A private key, on the backend only |
+| Backend API | Resource, an absolute URI such as `https://api.example.com`, with its scopes | Nothing; it names the audience |
+
+Allow both applications the resource with `PUT /admin/applications/:id/resources`. Then link the
+backend client to the desktop app (API v0.23.0) with
+`PUT /admin/applications//links/`. A link says the backend acts for that
+app. The acting client must be confidential and the linked one public, and both must be first-party
+or both third-party. Every write needs a fresh step-up and is audited.
+
+### Verify Tokens In The Backend
+
+Use the verify-only guards of your adapter, as in
+[Verify Tokens In Your API](/build/openid-provider#verify-tokens-in-your-api). They check the
+signature, `iss`, `aud`, `typ` and `exp` locally, and never call the auth API. One check is yours:
+which clients this API serves. The adapter accepts any valid token for the resource, so without
+this, another client allowed the same resource could call it. In the example, the backend's own
+client credentials token verifies, and this check is what answers it with a 403.
+
+```ts
+import { createResourceServer } from '@seamless-auth/express'
+
+const api = createResourceServer({
+ issuer: 'https://login.example.com',
+ audience: 'https://api.example.com',
+})
+
+const clients = new Set(['DESKTOP_CLIENT_ID'])
+function requireClient(req, res, next) {
+ if (clients.has(req.user.clientId)) return next()
+ res.status(403).json({ error: 'client_not_allowed' })
+}
+
+app.get('/notes', api.requireAuth(), requireClient, api.requireScope('notes:read'), listNotes)
+```
+
+### Require A Recent Passkey On One Route
+
+The backend decides which routes are sensitive. `requireStepUp` answers with the RFC 9470 challenge
+until the token shows what the route asks for:
+
+```ts
+app.post(
+ '/notes/export',
+ api.requireAuth(),
+ requireClient,
+ api.requireScope('notes:read'),
+ api.requireStepUp({ acrValues: ['phr'], maxAge: 300 }),
+ exportNotes,
+)
+```
+
+```http
+HTTP/1.1 401 Unauthorized
+WWW-Authenticate: Bearer error="insufficient_user_authentication",
+ error_description="A different authentication level is required", acr_values="phr", max_age="300"
+```
+
+The app reads the challenge, accepting quoted or bare values, and then:
+
+1. Opens the system browser on a new authorization request with the same `resource` and scopes,
+ plus the `acr_values` and `max_age` from the challenge.
+2. The user confirms on the hosted page, which offers their passkey on the session they already
+ have.
+3. The app exchanges the code, stores the new refresh token, and retries the call once.
+4. The app revokes the refresh token it replaced. A step-up starts a new grant, so the old one
+ would otherwise stay usable until it expires.
+
+`acr` says how strong and `max_age` says how recent, so a route that needs a passkey within five
+minutes asks for both. The challenge comes back once `auth_time` is older than `max_age`. Do not
+use the device flow for such a route, since device tokens never carry `acr`.
+
+### Send A Notice Through The Client Link
+
+Users sign in to the desktop app, so the backend's own client holds no grants and could reach
+nobody. A link fixes that. The backend gets a client credentials token with its own key and names
+the desktop app in `clientId`. The template names the app the user knows, not the backend.
+
+
+
+```ts
+const { access_token } = await oidc.clientCredentialsGrant(backendConfig, {
+ resource: 'https://login.example.com/seamless',
+ scope: 'notices:send',
+})
+
+await fetch('https://auth-api.internal.example.com/applications/account-notices', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${access_token}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ userId: req.user.id,
+ clientId: 'DESKTOP_CLIENT_ID',
+ subject: 'Your notes export is ready',
+ text: 'The export you asked for is ready in the app.',
+ }),
+})
+```
+
+A user token cannot send a notice, and neither can a public client, which cannot use
+`client_credentials`. The backend's private key never ships in the app.
+
+---
+
+## A Runnable Desktop Example
+
+[`examples/native-desktop`](https://github.com/fells-code/seamless-auth-docs/tree/main/examples/native-desktop)
+is an Electron app and an Express backend that do everything on this page against a local instance:
+system browser sign-in, a loopback redirect on a port the OS picks, PKCE, the OS keychain, refresh,
+sign-out with revocation, a token for a named API, a route that answers the RFC 9470 challenge until
+the user steps up, and a notice through a client link. Its README has the steps, and it runs on the
+[local recipe](https://github.com/fells-code/seamless-auth-oidc-provider/pull/60), which is still in
+review.
+
+CI runs it end to end on a schedule, with a virtual authenticator in Chromium standing in for the
+user's passkey and default browser. That covers the flow and the keychain. It does not cover a real
+browser being opened by the OS, a physical authenticator, or Windows. The README lists exactly what
+it does and does not check.
+
+---
+
+## A Runnable CLI Example
+
+
+
+---
+
+## Where DPoP Will Fit
+
+
+
+Today, every token on this page is a bearer token: whoever holds it can use it, which is why the
+storage rules above are the protection. DPoP (RFC 9449) binds a token to a key the app holds, so a
+stolen token alone would not be enough. When #512 ships, this page should recommend a non-exportable
+key in the platform keystore where one exists. On the backend, the verify-only guards already refuse
+a sender-constrained token (`cnf`) unless you pass a `confirm` hook, which is where a proof check
+would plug in.
+
---
## What OAuth 2.1 Refuses
@@ -257,5 +479,8 @@ public one never):
- [Use Seamless Auth As An OpenID Provider](/build/openid-provider)
- [Mobile Passkeys](/build/mobile-passkeys)
+- [Server SDKs](/build/server-sdks)
- [OAuth 2.0 Security Best Current Practice (RFC 9700)](/understand/oauth-rfc9700)
- [RFC 8252: OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252)
+- [RFC 9470: OAuth 2.0 Step Up Authentication Challenge Protocol](https://www.rfc-editor.org/rfc/rfc9470)
+- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)
diff --git a/src/content/docs/build/openid-provider.mdx b/src/content/docs/build/openid-provider.mdx
index 1175f60..1bbb6d5 100644
--- a/src/content/docs/build/openid-provider.mdx
+++ b/src/content/docs/build/openid-provider.mdx
@@ -846,6 +846,11 @@ for the application, so an app cannot learn who else has an account), `notice_ac
(400), `notice_no_channel` (422) and `notice_rate_limited` (429). The limits are 300 notices an hour
per application and 10 an hour per application and user.
+A backend that serves a public client, such as a desktop app, cannot reach that app's users this way,
+because users hold grants with the app and not with the backend. Client links solve that, and they
+need Seamless Auth API v0.23.0, which is not published yet. See
+[Send A Notice Through The Client Link](/build/native-apps-oidc#send-a-notice-through-the-client-link).
+
---
## Verify Tokens In Your API
diff --git a/tsconfig.json b/tsconfig.json
index 8bf91d3..0e7f3e6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
- "exclude": ["dist"]
+ "exclude": ["dist", "examples"]
}