Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/native-desktop-example.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions examples/native-desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
e2e/screenshots/
114 changes: 114 additions & 0 deletions examples/native-desktop/README.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions examples/native-desktop/app/backend-client.mjs
Original file line number Diff line number Diff line change
@@ -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 }
}
}
30 changes: 30 additions & 0 deletions examples/native-desktop/app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'"
/>
<title>Desk Notes</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<h1>Desk Notes</h1>
<p id="who" aria-live="polite">Loading</p>
<div class="actions">
<button data-action="signIn">Sign in with your browser</button>
<button data-action="listNotes">List notes</button>
<button data-action="exportNotes">Export notes (recent passkey)</button>
<button data-action="sendNotice">Email me a notice</button>
<button data-action="refresh">Refresh tokens</button>
<button data-action="signOut">Sign out</button>
<button data-action="signOutEverywhere">Sign out of Seamless too</button>
</div>
<h2>Last result</h2>
<pre id="result" tabindex="0"></pre>
<h2>Access token claims</h2>
<pre id="claims" tabindex="0"></pre>
<script src="./renderer.js" type="module"></script>
</body>
</html>
80 changes: 80 additions & 0 deletions examples/native-desktop/app/main.mjs
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading