diff --git a/.github/workflows/native-cli-example.yaml b/.github/workflows/native-cli-example.yaml new file mode 100644 index 0000000..8ee09e6 --- /dev/null +++ b/.github/workflows/native-cli-example.yaml @@ -0,0 +1,81 @@ +name: native-cli-example + +# Keeps examples/native-cli running: the CLI against the OpenID Provider local recipe, both flows, +# 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-cli/**' + - '.github/workflows/native-cli-example.yaml' + schedule: + - cron: '11 6 * * 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), though this example uses nothing new in it. + RECIPE_REF: 30be59b557ad109a93aff68695f0fb21ff2ef84d + +jobs: + example: + name: CLI, loopback and device flows, end to end + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: examples/native-cli + 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-cli/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 dbus-x11 gnome-keyring + npx playwright install --with-deps chromium + + - name: Register the CLI's client + run: node scripts/register-client.mjs "${GITHUB_WORKSPACE}/.recipe/local" + + - name: Run the example end to end + env: + RECIPE_DIR: ${{ github.workspace }}/.recipe/local + run: 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-cli-screenshots + path: examples/native-cli/e2e/screenshots/ + if-no-files-found: ignore + retention-days: 7 diff --git a/examples/native-cli/.gitignore b/examples/native-cli/.gitignore new file mode 100644 index 0000000..852b7ff --- /dev/null +++ b/examples/native-cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +e2e/screenshots/ diff --git a/examples/native-cli/README.md b/examples/native-cli/README.md new file mode 100644 index 0000000..5016f71 --- /dev/null +++ b/examples/native-cli/README.md @@ -0,0 +1,142 @@ +# Native CLI example + +A small command line tool on the Seamless Auth OpenID Provider, with both sign-in flows. It is the +CLI half of the runnable examples for the +[native apps guide](https://docs.seamlessauth.com/build/native-apps-oidc), beside +[`examples/native-desktop`](../native-desktop), and its end-to-end run doubles as a conformance +check. Nothing in it is specific to one product. "Notes" is a stand-in. + +| Path | What it is | +| ---------------------- | -------------------------------------------------------------------------------- | +| `lib/oidc-session.mjs` | Loopback listener, PKCE, device grant, code exchange, refresh, revocation | +| `lib/token-store.mjs` | Tokens in the OS credential store, with no file fallback | +| `lib/refresh-lock.mjs` | One refresher across every process of the CLI | +| `lib/open-browser.mjs` | The system browser, by way of the OS, with `BROWSER` as an override | +| `bin/notes.mjs` | The commands: `login`, `login --device`, `status`, `token`, `logout` | +| `scripts/` | Registers the CLI's client, and reads the local recipe's settings | +| `e2e/run.mjs` | The whole thing, running the real CLI as a child process and driving the browser | + +## Why these rules + +- **No client secret.** Anything shipped in a binary can be read out of it, so this is a public + client and PKCE (`S256`) protects the code. Neither flow sends a secret. +- **The system browser, never a webview.** The CLI never sees the user's credentials, and a passkey + works, because the hosted page runs on the relying party's own domain. +- **A loopback redirect on a port the OS picks,** on `127.0.0.1` only, for one redirect (RFC 8252). +- **The device grant when there is no browser** (RFC 8628): over SSH, or in a container. Nothing has + to reach back to the machine. Device tokens never carry `acr`, so do not use it for an API that + requires a phishing-resistant sign-in. +- **Tokens in the OS keychain,** with no fallback to a file. If there is no keychain, the command + fails and says so. +- **The access token is in the keychain too,** which is where a CLI differs from a desktop app. A + desktop app keeps it in memory, but each CLI command is a new process, and without it every + command would spend a refresh. +- **A lock around refresh.** Refresh tokens rotate, and a token used twice revokes the grant. Two + commands in a pipeline are enough to do that, so each refresh takes a lock and reads the keychain + again inside it. +- **stdout is for results.** Prompts and errors go to stderr, so + `curl -H "Authorization: Bearer $(notes token)"` works. + +## 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/tree/main/local) of + `seamless-auth-oidc-provider`, at commit `30be59b`. +- **The recipe builds Seamless Auth API v0.23.0 from source, because that release is not published + yet.** This example uses nothing new in it: the recipe needs it for the client links in its seed. + Neither the example nor its check has been run against a published API. + +## 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/register-client.mjs "$RECIPE" +eval "$(node scripts/recipe-env.mjs "$RECIPE")" + +node bin/notes.mjs login # or: login --device +node bin/notes.mjs status +node bin/notes.mjs token +node bin/notes.mjs logout +``` + +`register-client.mjs` is there because the recipe's seed registers a desktop client with no device +grant. On your own instance, an admin registers the same record with the CLI: + +```bash +seamless clients create "Notes CLI" --public --first-party --auth-method none \ + --redirect-uri http://127.0.0.1/callback \ + --grants authorization_code,refresh_token --device-flow +seamless clients resources --set =notes:read,notes:write +``` + +`login` opens your default browser, 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. `login --device` +has the same need, on whichever browser you approve from. Read the sign-in code at the recipe's +mailbox, `http://127.0.0.1:9025` by default. The settings are environment variables so the example +stays visibly generic. A real CLI 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 Secret Service: + +```bash +dbus-run-session -- bash e2e/with-keyring.sh node e2e/run.mjs +``` + +[CI](../../.github/workflows/native-cli-example.yaml) runs that on pull requests that touch this +directory and every week. + +Each run registers a new user, and the sign-in rate limits stay on in the recipe (15 minutes, per +address). A few runs in a row start failing on the hosted page with "Failed to register" or +"Verification failed". Wait, or restart the recipe's `api` service, which clears the counters. + +| It runs for real | It stands in | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| The CLI, as a new process for each command, with no test code | The default browser: Chromium from Playwright, handed the URL through the `BROWSER` variable | +| The OS keychain entries, read from outside the CLI | The passkey: a virtual authenticator in that Chromium | +| The edge and the API | The mail provider: the recipe's mailbox | +| The requests the CLI sends, recorded by a `node --import` hook | The second device for `--device`: the same Chromium, on the same machine | + +It checks the authorization request (PKCE `S256`, no secret, loopback on an OS-picked port), that the +listener refuses a wrong `state`, is not reachable off loopback and closes afterwards, that no request +in either flow carries a secret, that the token verifies against the JWKS with `at+jwt`, `iss`, `aud`, +and the scopes, that the refresh token is in the keychain and not in the output or in the CLI's +working or state directories, that a new command reuses the stored token, refresh and rotation, six +commands at once spending one refresh, a stale lock being taken over, sign-out with revocation, the +device flow's code, polling interval, a wrong code and a denied approval, and that device tokens have +no `acr`, after a refresh too. + +It does not check: + +- The OS actually opening a browser, or a real passkey or security key. The per-OS commands in + `lib/open-browser.mjs` have not been run by this check. +- Windows Credential Manager, or a packaged and signed build of the CLI. +- A locked keychain, or a machine with none. +- A device code that expires, or `slow_down`. The CLI waits the interval, so it does not earn one. +- That the CLI wrote no file anywhere on the disk. It looks in the working and state directories. +- A released API. Until v0.23.0, it runs on the commit the recipe pins. + +## Why a standalone CLI, and not `seamless profile login` + +The issue this answers asks for an example an adopter can copy, with nothing specific to one product. +`seamless profile login` is the Seamless CLI signing in to its own instance: after the provider +sign-in, it spends the token at `POST /sessions/exchange` for a first-party admin session and keeps +no provider token, which is not what an adopter's CLI does. An adopter's CLI keeps the provider's +tokens and calls its own API, which is what this one shows. The guide covers +[`seamless profile login`](https://docs.seamlessauth.com/build/native-apps-oidc#a-runnable-cli-example) +as well, and that CLI has its own end-to-end tests in its repository. + +## 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-cli/bin/notes.mjs b/examples/native-cli/bin/notes.mjs new file mode 100644 index 0000000..f484fe4 --- /dev/null +++ b/examples/native-cli/bin/notes.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { createOidcSession } from '../lib/oidc-session.mjs' +import { openBrowser } from '../lib/open-browser.mjs' +import { withRefreshLock } from '../lib/refresh-lock.mjs' +import { createTokenStore } from '../lib/token-store.mjs' + +const USAGE = `Usage: notes + + login Sign in through your browser + login --device Sign in from another device, for a machine with no browser + status Show who is signed in + token Print an access token for the API, refreshing it if needed + logout Revoke this CLI's grant and delete its tokens` + +const { OIDC_ISSUER, OIDC_CLIENT_ID, API_RESOURCE, API_SCOPES } = process.env +const say = (line) => console.error(line) + +function claimsOf(jwt) { + // Shown to the user only. The API the token is for is what verifies it. + return JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString()) +} + +async function run([command, ...args]) { + if (!command || command === 'help' || command === '--help') { + console.log(USAGE) + return 0 + } + if (!OIDC_ISSUER || !OIDC_CLIENT_ID || !API_RESOURCE) { + say( + 'Set OIDC_ISSUER, OIDC_CLIENT_ID and API_RESOURCE. A real CLI would compile them in. None is a secret.', + ) + return 2 + } + + const lockDir = process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state') + const lockPath = join(lockDir, 'notes-cli', 'refresh.lock') + const session = createOidcSession({ + issuer: OIDC_ISSUER, + clientId: OIDC_CLIENT_ID, + resource: API_RESOURCE, + scopes: API_SCOPES?.split(' ').filter(Boolean) ?? [], + store: createTokenStore({ + service: 'com.example.notes-cli', + account: `${OIDC_ISSUER} ${OIDC_CLIENT_ID}`, + }), + withLock: (work) => withRefreshLock(lockPath, work), + openBrowser, + say, + }) + + switch (command) { + case 'login': { + await (args.includes('--device') ? session.signInWithDevice() : session.signInWithBrowser()) + const { sub } = claimsOf(await session.accessToken()) + say(`Signed in as ${sub}.`) + return 0 + } + case 'status': { + const { sub, aud, scope, acr, amr, exp } = claimsOf(await session.accessToken()) + console.log(JSON.stringify({ sub, aud, scope, acr, amr, exp }, null, 2)) + return 0 + } + case 'token': + console.log(await session.accessToken()) + return 0 + case 'logout': { + const { revoked } = await session.signOut() + say(revoked ? 'Signed out, and the grant is revoked.' : 'Already signed out.') + return 0 + } + default: + say(USAGE) + return 2 + } +} + +// What the provider answered, in words. openid-client's own message does not name it. +function describe(error) { + switch (error.error) { + case 'access_denied': + return 'The sign-in was denied in the browser.' + case 'expired_token': + return 'The code expired before it was approved. Run the command again.' + case 'invalid_grant': + return 'That sign-in is no longer valid. Run: notes login' + case undefined: + return error.message + default: + return `The provider refused the request: ${error.error}` + } +} + +try { + process.exitCode = await run(process.argv.slice(2)) +} catch (error) { + say(describe(error)) + process.exitCode = 1 +} diff --git a/examples/native-cli/e2e/keychain.mjs b/examples/native-cli/e2e/keychain.mjs new file mode 100644 index 0000000..8781b88 --- /dev/null +++ b/examples/native-cli/e2e/keychain.mjs @@ -0,0 +1,26 @@ +// Reads or changes the CLI's keychain entries from outside the CLI. +import { AsyncEntry } from '@napi-rs/keyring' + +const [operation, service, account] = process.argv.slice(2) +const entry = (name) => + new AsyncEntry(service, `${account} ${name}`, { linux: { store: 'secret-service' } }) +if (operation === 'read') { + process.stdout.write( + JSON.stringify({ + refresh: await entry('refresh').getPassword(), + access: await entry('access').getPassword(), + }), + ) +} else if (operation === 'forget-access') { + await entry('access') + .deleteCredential() + .catch(() => {}) +} else if (operation === 'delete') { + await entry('refresh') + .deleteCredential() + .catch(() => {}) + await entry('access') + .deleteCredential() + .catch(() => {}) +} +process.exit(0) diff --git a/examples/native-cli/e2e/record-fetch.mjs b/examples/native-cli/e2e/record-fetch.mjs new file mode 100644 index 0000000..8e2b67d --- /dev/null +++ b/examples/native-cli/e2e/record-fetch.mjs @@ -0,0 +1,21 @@ +// Loaded into the CLI by the e2e with `node --import`, so the CLI itself has no test code. Records +// what it sends, one JSON line per request, to the file the e2e names. +import { appendFileSync } from 'node:fs' + +const realFetch = globalThis.fetch +globalThis.fetch = async (input, init = {}) => { + const request = new Request(input, init) + const response = await realFetch(input, init) + appendFileSync( + process.env.E2E_TRAFFIC_FILE, + `${JSON.stringify({ + at: Date.now(), + url: request.url, + method: request.method, + body: init.body ? String(init.body) : '', + authorization: request.headers.get('authorization'), + status: response.status, + })}\n`, + ) + return response +} diff --git a/examples/native-cli/e2e/run.mjs b/examples/native-cli/e2e/run.mjs new file mode 100644 index 0000000..57064dc --- /dev/null +++ b/examples/native-cli/e2e/run.mjs @@ -0,0 +1,612 @@ +// Runs the example end to end against a local recipe: the real CLI as a child process, its real +// keychain entries, and the hosted page in Chromium with a virtual authenticator. Chromium stands in +// for the user's default browser, through the BROWSER variable the CLI honours, so the CLI has no +// test code in it. Needs the recipe up and scripts/register-client.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 { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs' +import { networkInterfaces, tmpdir } from 'node:os' +import { join } from 'node:path' +import { connect } from 'node:tls' +import { fileURLToPath } from 'node:url' + +import { createRemoteJWKSet, jwtVerify } from 'jose' +import * as oidc from 'openid-client' +import { chromium } from 'playwright' + +import { readRecipe } from '../scripts/recipe.mjs' + +const CONCURRENT_COMMANDS = 6 +const root = fileURLToPath(new URL('..', import.meta.url)) +const shots = process.env.SCREENSHOT_DIR ?? join(root, 'e2e', 'screenshots') +mkdirSync(shots, { recursive: true }) + +if (!process.env.RECIPE_DIR) { + console.error('Set RECIPE_DIR to the recipe local directory.') + process.exit(1) +} +const { recipe, cli: cliEnv, mailboxUrl, appName } = readRecipe(process.env.RECIPE_DIR) +if (!cliEnv.OIDC_CLIENT_ID) { + console.error('Run scripts/register-client.mjs first.') + process.exit(1) +} +const caFile = cliEnv.NODE_EXTRA_CA_CERTS +const clientId = cliEnv.OIDC_CLIENT_ID +const issuerUrl = new URL(recipe.issuer) +const email = `cli+${Date.now()}@${appName}.localhost` +const service = 'com.example.notes-cli' +const account = `${recipe.issuer} ${clientId}` + +// 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 work = mkdtempSync(join(tmpdir(), 'notes-cli-e2e-')) +const stateDir = join(work, 'state') +const cwd = join(work, 'cwd') +mkdirSync(cwd) +const lockFile = join(stateDir, 'notes-cli', 'refresh.lock') +const trafficFile = join(work, 'traffic.jsonl') +const openedFile = join(work, 'opened.txt') +const opener = join(work, 'browser') +writeFileSync(opener, '#!/bin/sh\nprintf \'%s\\n\' "$1" >> "$E2E_OPENED_FILE"\n') +chmodSync(opener, 0o755) + +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) + } +} + +const readLines = (file) => + existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean) : [] +const traffic = () => readLines(trafficFile).map((line) => JSON.parse(line)) +const opened = () => readLines(openedFile) +const clearRecords = () => { + rmSync(trafficFile, { force: true }) + rmSync(openedFile, { force: true }) +} + +/** The CLI as a user runs it. `done` settles with its exit code and everything it printed. */ +function notes(...args) { + const child = spawn( + process.execPath, + ['--import', join(root, 'e2e', 'record-fetch.mjs'), join(root, 'bin', 'notes.mjs'), ...args], + { + cwd, + env: { + ...process.env, + ...cliEnv, + BROWSER: opener, + XDG_STATE_HOME: stateDir, + E2E_TRAFFIC_FILE: trafficFile, + E2E_OPENED_FILE: openedFile, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const output = { stdout: '', stderr: '' } + child.stdout.on('data', (chunk) => (output.stdout += chunk)) + child.stderr.on('data', (chunk) => (output.stderr += chunk)) + const done = new Promise((resolve) => { + child.on('close', (code) => resolve({ code, ...output })) + }) + return { child, output, done } +} +const run = (...args) => notes(...args).done + +function keychain(operation) { + const { stdout, status, stderr } = spawnSync( + process.execPath, + [join(root, 'e2e', 'keychain.mjs'), operation, service, account], + { cwd: root, encoding: 'utf8' }, + ) + if (status !== 0) throw new Error(`keychain ${operation}: ${stderr}`) + return operation === 'read' ? JSON.parse(stdout) : 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) + if (readFileSync(path).includes(needle)) hits.push(path) + } + return hits +} +const filesTheCliCouldWrite = (needle) => [ + ...filesContaining(cwd, needle), + ...(existsSync(stateDir) ? filesContaining(stateDir, needle) : []), +] + +// 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 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, + }, +}) + +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) + } + // The page submits by itself on the last digit, and a second submit of a spent code fails it. + const moved = await page + .waitForURL(/register-passkey/, { timeout: 3000 }) + .then(() => true) + .catch(() => false) + if (!moved) + await page + .getByRole('button', { name: /verify|continue|submit/i }) + .first() + .click() + await page.waitForURL(/register-passkey/, { timeout: 20_000 }) + 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() + } +} + +// Presses what the hosted page offers (a passkey, then consent) until `finished` says it is done. +async function finishInteraction(label, finished) { + const deadline = Date.now() + 45_000 + let shot = false + while (Date.now() < deadline && !(await finished())) { + const offered = page + .getByRole('button', { name: /passkey|^(allow|continue|accept|confirm|verify|yes)/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 (!(await finished())) throw new Error(`${label}: the hosted page never finished`) +} + +const openedUrl = () => until('the CLI to open the browser', () => opened().at(-1)) + +let exitCode = 1 +try { + const publicConfig = await oidc.discovery(issuerUrl, clientId, undefined, oidc.None()) + const jwks = createRemoteJWKSet(new URL(publicConfig.serverMetadata().jwks_uri)) + const verifyToken = async (token) => + ( + await jwtVerify(token, jwks, { + issuer: recipe.issuer, + audience: recipe.resource, + typ: 'at+jwt', + }) + ).payload + const refreshGrants = () => + traffic().filter((call) => call.body.includes('grant_type=refresh_token')) + keychain('delete') + + console.log('\nBefore any sign-in') + const anonymous = await run('token') + check('`token` exits 1', anonymous.code === 1, JSON.stringify(anonymous)) + check('and says to sign in', /notes login/.test(anonymous.stderr), anonymous.stderr) + check('and prints nothing to stdout', anonymous.stdout === '') + const empty = keychain('read') + check('the keychain starts empty', empty.refresh === null && empty.access === null) + + const source = readdirSync(join(root, 'lib')) + .map((file) => readFileSync(join(root, 'lib', file), 'utf8')) + .concat(readFileSync(join(root, 'bin', 'notes.mjs'), 'utf8')) + .join('\n') + check( + 'the source has no secret, no embedded browser and no file storage for tokens', + !/client_secret|BrowserWindow|webview|writeFile/.test(source), + ) + + console.log('\nLoopback: the system browser, PKCE and a redirect back to this machine') + clearRecords() + const login = notes('login') + const firstUrl = new URL(await openedUrl()) + 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, + ) + check( + 'the URL is also printed, for when no browser opens', + login.output.stderr.includes(firstUrl.href), + ) + check('stdout stays empty during sign-in', login.output.stdout === '') + + 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 page.goto(firstUrl.href) + 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() + await finishInteraction('register', async () => atLoopback()) + await page.screenshot({ path: join(shots, 'back-at-cli.png') }) + + const loggedIn = await login.done + check('`login` exits 0', loggedIn.code === 0, JSON.stringify(loggedIn)) + check( + 'the listener is closed afterwards', + await fetch(firstRedirect.href).then( + () => false, + () => true, + ), + ) + + const codeExchange = traffic().find((call) => call.body.includes('authorization_code')) + const exchangeParams = new URLSearchParams(codeExchange.body) + check( + 'the code exchange sends the verifier and client_id, and no secret', + exchangeParams.has('code_verifier') && + exchangeParams.get('client_id') === clientId && + !exchangeParams.has('client_secret') && + !codeExchange.authorization, + ) + + const signedIn = keychain('read') + check('the refresh token is in the OS keychain', Boolean(signedIn.refresh)) + check('the access token is in the OS keychain', Boolean(signedIn.access)) + const leaked = filesTheCliCouldWrite(signedIn.refresh) + check('the refresh token is in no file the CLI wrote', leaked.length === 0, leaked.join()) + check( + 'and was not printed', + !loggedIn.stderr.includes(signedIn.refresh) && !loggedIn.stdout.includes(signedIn.refresh), + ) + check('the state directory holds nothing after sign-in', !existsSync(lockFile)) + + console.log('\nThe access token') + const printed = await run('token') + const accessToken = printed.stdout.trim() + check('`token` prints only the token', accessToken.split('.').length === 3, printed.stdout) + const claims = await verifyToken(accessToken) + console.log(` claims: ${JSON.stringify(claims)}`) + check('it verifies against the JWKS, with typ at+jwt, iss and aud', true) + check('it names this client', claims.client_id === clientId, claims.client_id) + check( + 'it has the scopes asked for', + recipe.scopes.every((scope) => claims.scope?.split(' ').includes(scope)), + claims.scope, + ) + check('registering used an email code, so no acr', claims.acr === undefined, claims.acr) + const status = await run('status') + check('`status` shows the same subject', JSON.parse(status.stdout).sub === claims.sub) + + console.log('\nA new command reuses the stored token, with no browser and no refresh') + clearRecords() + check('the same access token', (await run('token')).stdout.trim() === accessToken) + check('no refresh', refreshGrants().length === 0) + check('no browser', opened().length === 0) + + console.log('\nRefresh') + keychain('forget-access') + clearRecords() + const refreshed = await run('token') + check('with the access token gone, `token` still answers', refreshed.code === 0) + check('one refresh', refreshGrants().length === 1) + check( + 'with no secret', + !refreshGrants()[0].body.includes('client_secret') && !refreshGrants()[0].authorization, + ) + check('for the API', (await verifyToken(refreshed.stdout.trim())).aud === recipe.resource) + const afterRefresh = keychain('read') + check('the refresh token rotated in the keychain', afterRefresh.refresh !== signedIn.refresh) + + console.log(`\n${CONCURRENT_COMMANDS} commands at once, with nothing fresh to reuse`) + keychain('forget-access') + clearRecords() + const together = await Promise.all( + Array.from({ length: CONCURRENT_COMMANDS }, () => run('token')), + ) + check( + 'all of them succeed', + together.every((result) => result.code === 0), + JSON.stringify(together.map((result) => result.stderr)), + ) + check( + 'with one token between them', + new Set(together.map((result) => result.stdout.trim())).size === 1, + ) + check('from one refresh', refreshGrants().length === 1, String(refreshGrants().length)) + check('the lock is released', !existsSync(lockFile)) + keychain('forget-access') + check('the grant is still alive', (await run('token')).code === 0) + + console.log('\nA lock left behind by a killed command') + mkdirSync(join(stateDir, 'notes-cli'), { recursive: true }) + writeFileSync(lockFile, '') + const longAgo = new Date(Date.now() - 120_000) + utimesSync(lockFile, longAgo, longAgo) + keychain('forget-access') + const afterStale = await run('token') + check('the next command takes it over', afterStale.code === 0, afterStale.stderr) + + console.log('\nSign out with revocation') + const lastRefresh = keychain('read').refresh + clearRecords() + const signedOut = await run('logout') + check('`logout` exits 0 and says it revoked', /revoked/.test(signedOut.stderr), signedOut.stderr) + check( + 'through the revocation endpoint', + traffic().some((call) => call.url.endsWith('/token/revocation') && call.status === 200), + ) + const cleared = keychain('read') + check('the keychain is empty', cleared.refresh === null && cleared.access === null) + 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) + check('`token` says to sign in again', (await run('token')).code === 1) + + console.log('\nThe browser session outlives a CLI sign-out') + clearRecords() + const secondLogin = notes('login') + const secondUrl = new URL(await openedUrl()) + await page.goto(secondUrl.href) + await finishInteraction('second-sign-in', async () => atLoopback()) + check('`login` exits 0', (await secondLogin.done).code === 0) + check( + 'on another port', + new URL(secondUrl.searchParams.get('redirect_uri')).port !== firstRedirect.port, + ) + const beforeDevice = keychain('read').refresh + + console.log('\nThe device grant: a sign-in that never reaches back to this machine') + clearRecords() + const deviceLogin = notes('login', '--device') + const prompt = await until('the code on the terminal', () => { + const match = deviceLogin.output.stderr.match(/go to:\s+(\S+)\s+and enter this code:\s+(\S+)/) + return match && { uri: match[1], userCode: match[2] } + }) + check('the code is printed with the provider`s page', prompt.uri.startsWith(recipe.issuer)) + check( + 'the code looks like XXXX-XXXX', + /^[A-Z]{4}-[A-Z]{4}$/.test(prompt.userCode), + prompt.userCode, + ) + check('no browser is opened', opened().length === 0) + check('stdout stays empty', deviceLogin.output.stdout === '') + const started = traffic().find((call) => call.url.endsWith('/device/auth')) + const startedParams = new URLSearchParams(started.body) + check( + 'the device request has client_id and no secret', + startedParams.get('client_id') === clientId && + !startedParams.has('client_secret') && + !started.authorization, + ) + check('and names the API', startedParams.get('resource') === recipe.resource) + const pollsOf = () => traffic().filter((call) => call.body.includes('device_code')) + await until('the first poll', () => pollsOf().length > 0) + check('before approval a poll is told to wait', pollsOf()[0].status === 400) + check('and the CLI keeps waiting', deviceLogin.child.exitCode === null) + + await page.goto(prompt.uri) + await page.screenshot({ path: join(shots, 'device-input.png') }) + await page.locator('input[name="user_code"], input[type="text"]').first().fill('BCDF-GHJK') + await page + .getByRole('button', { name: /continue|submit|next/i }) + .first() + .click() + await page + .getByText(/not found|invalid|incorrect|expired|could not|no such/i) + .first() + .waitFor() + check('a wrong code is refused on the page', true) + check('the CLI is still waiting', deviceLogin.child.exitCode === null) + + await page.locator('input[name="user_code"], input[type="text"]').first().fill(prompt.userCode) + await page + .getByRole('button', { name: /continue|submit|next/i }) + .first() + .click() + await finishInteraction('device', async () => + Boolean( + deviceLogin.child.exitCode !== null || + (await page.getByText(/you can close|return to|all set|approved/i).count()), + ), + ) + await page.screenshot({ path: join(shots, 'device-done.png') }) + + const deviceDone = await deviceLogin.done + check('`login --device` exits 0', deviceDone.code === 0, JSON.stringify(deviceDone)) + const polls = pollsOf() + check( + 'each poll has the device grant and no secret', + polls.every( + (call) => + call.body.includes('grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code') && + !call.body.includes('client_secret') && + !call.authorization, + ), + ) + const gaps = polls.slice(1).map((call, i) => call.at - polls[i].at) + check( + 'polls wait the interval, so none earns slow_down', + polls.length > 1 && gaps.every((gap) => gap >= 4900), + gaps.join(), + ) + + const deviceKeys = keychain('read') + check('the device sign-in is in the keychain', Boolean(deviceKeys.refresh)) + check('it replaced the earlier grant', deviceKeys.refresh !== beforeDevice) + const superseded = await oidc + .refreshTokenGrant(publicConfig, beforeDevice, { resource: recipe.resource }) + .then( + () => null, + (error) => error.error, + ) + check('and revoked it', superseded === 'invalid_grant', superseded) + const deviceClaims = await verifyToken((await run('token')).stdout.trim()) + console.log(` claims: ${JSON.stringify(deviceClaims)}`) + check('the token is for the same user', deviceClaims.sub === claims.sub) + check('device tokens never state acr, whatever approved them', deviceClaims.acr === undefined) + keychain('forget-access') + const deviceRefreshed = await verifyToken((await run('token')).stdout.trim()) + check('it refreshes', deviceRefreshed.sub === claims.sub) + check('still with no acr', deviceRefreshed.acr === undefined) + + console.log('\nA device sign-in the user turns down') + await run('logout') + clearRecords() + const denied = notes('login', '--device') + const deniedPrompt = await until('the code on the terminal', () => { + const match = denied.output.stderr.match(/go to:\s+(\S+)\s+and enter this code:\s+(\S+)/) + return match && { uri: match[1], userCode: match[2] } + }) + await page.goto(deniedPrompt.uri) + await page + .locator('input[name="user_code"], input[type="text"]') + .first() + .fill(deniedPrompt.userCode) + await page + .getByRole('button', { name: /continue|submit|next/i }) + .first() + .click() + await page + .getByRole('button', { name: /cancel|deny|abort/i }) + .first() + .click() + const refusal = await denied.done + check('`login --device` exits 1', refusal.code === 1, JSON.stringify(refusal)) + const afterDenial = keychain('read') + check('and says so', /denied/i.test(refusal.stderr), refusal.stderr) + check('nothing reached the keychain', afterDenial.refresh === null) + + 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(() => ''), + ) +} finally { + await browser.close() + keychain('delete') + rmSync(work, { recursive: true, force: true }) +} +process.exit(exitCode) diff --git a/examples/native-cli/e2e/with-keyring.sh b/examples/native-cli/e2e/with-keyring.sh new file mode 100755 index 0000000..bc09c8d --- /dev/null +++ b/examples/native-cli/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-cli/lib/oidc-session.mjs b/examples/native-cli/lib/oidc-session.mjs new file mode 100644 index 0000000..da407c2 --- /dev/null +++ b/examples/native-cli/lib/oidc-session.mjs @@ -0,0 +1,196 @@ +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) => + `Notes CLI

${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) { + 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 this command started.') + } + send(200, 'Back to your terminal. You can close this tab.') + deliver(url.search) + }) + const timer = setTimeout( + () => fail(new Error('The sign-in timed out. Run the command again.')), + SIGN_IN_TIMEOUT_MS, + ) + + 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: () => { + clearTimeout(timer) + server.close() + server.closeAllConnections() + }, + } +} + +/** + * The CLI's side of RFC 8252 and RFC 8628: a public client with no secret, either flow, and every + * token in the OS credential store. Anything for the user to read goes to `say`, so stdout stays + * clean for `notes token`. + */ +export function createOidcSession({ + issuer, + clientId, + resource, + scopes, + store, + withLock, + openBrowser, + say, +}) { + const scope = ['openid', 'offline_access', ...scopes].join(' ') + let configuration + + 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 CLI holding the live refresh token, not a spent one. + if (tokens.refresh_token) await store.setRefresh(tokens.refresh_token) + const access = { + token: tokens.access_token, + expiresAt: Date.now() + (tokens.expires_in ?? 0) * 1000, + } + await store.setAccess(access) + return access + } + + /** Replaces whatever grant this CLI held, which would otherwise stay usable. */ + const replaceGrant = async (config, tokens) => + withLock(async () => { + const superseded = await store.getRefresh() + await adopt(tokens) + if (superseded) { + await oidc + .tokenRevocation(config, superseded, { token_type_hint: 'refresh_token' }) + .catch(() => {}) + } + }) + + /** Authorization code with PKCE, in the system browser, back to a loopback redirect. */ + async function signInWithBrowser() { + const config = await configure() + const state = oidc.randomState() + const nonce = oidc.randomNonce() + const verifier = oidc.randomPKCECodeVerifier() + const listener = await listenForRedirect(state) + + try { + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: listener.redirectUri, + response_type: 'code', + scope, + 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', + }).href + say(`Opening your browser to sign in. If it does not open, go to:\n\n ${url}\n`) + say('On a machine with no browser, use --device instead.') + openBrowser(url) + + const search = await listener.redirected + const tokens = await oidc.authorizationCodeGrant( + config, + new URL(`${listener.redirectUri}${search}`), + { pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce }, + { resource }, + ) + await replaceGrant(config, tokens) + } finally { + listener.close() + } + } + + /** The device grant, for a machine with no browser. Nothing has to reach back to this machine. */ + async function signInWithDevice() { + const config = await configure() + const device = await oidc.initiateDeviceAuthorization(config, { scope, resource }) + say(`On any device with a browser, go to:\n\n ${device.verification_uri}\n`) + say(`and enter this code:\n\n ${device.user_code}\n`) + say('Only continue if you started this sign-in yourself. Waiting for approval...') + const tokens = await oidc.pollDeviceAuthorizationGrant(config, device) + await replaceGrant(config, tokens) + } + + /** + * An access token, refreshed if it is about to expire. The second read inside the lock is what + * makes the lock work: the command that waited finds the token the first one just rotated. + */ + async function accessToken() { + const fresh = (access) => access && access.expiresAt - EXPIRY_MARGIN_MS > Date.now() + const cached = await store.getAccess() + if (fresh(cached)) return cached.token + + const config = await configure() + return withLock(async () => { + const current = await store.getAccess() + if (fresh(current)) return current.token + const refreshToken = await store.getRefresh() + if (!refreshToken) throw new Error('Not signed in. Run: notes login') + try { + return (await adopt(await oidc.refreshTokenGrant(config, refreshToken, { resource }))).token + } catch (error) { + if (error.error === 'invalid_grant') await store.delete() + throw error + } + }) + } + + /** Ends this CLI's grant. The user stays signed in to the browser and to their other apps. */ + async function signOut() { + const config = await configure() + return withLock(async () => { + const current = await store.getRefresh() + let revoked = false + try { + if (current) { + await oidc.tokenRevocation(config, current, { token_type_hint: 'refresh_token' }) + revoked = true + } + } finally { + await store.delete() + } + return { revoked } + }) + } + + return { signInWithBrowser, signInWithDevice, accessToken, signOut } +} diff --git a/examples/native-cli/lib/open-browser.mjs b/examples/native-cli/lib/open-browser.mjs new file mode 100644 index 0000000..f9f834e --- /dev/null +++ b/examples/native-cli/lib/open-browser.mjs @@ -0,0 +1,18 @@ +import { spawn } from 'node:child_process' + +function browserCommand(url) { + const override = process.env.BROWSER?.trim() + if (override) return [override, [url]] + if (process.platform === 'darwin') return ['open', [url]] + // Not `cmd /c start`: cmd would parse the & in the query string. + if (process.platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]] + return ['xdg-open', [url]] +} + +/** Best effort. The caller has already printed the URL, which covers a machine with no browser. */ +export function openBrowser(url) { + const [command, args] = browserCommand(url) + const child = spawn(command, args, { stdio: 'ignore', detached: true }) + child.once('error', () => {}) + child.unref() +} diff --git a/examples/native-cli/lib/refresh-lock.mjs b/examples/native-cli/lib/refresh-lock.mjs new file mode 100644 index 0000000..e687dfe --- /dev/null +++ b/examples/native-cli/lib/refresh-lock.mjs @@ -0,0 +1,38 @@ +import { mkdir, open, rm, stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +const POLL_MS = 100 +const STALE_MS = 30 * 1000 +const WAIT_MS = 45 * 1000 + +/** + * One refresher across every process of this CLI on the machine. Refresh tokens rotate and a token + * used twice revokes the grant, and two commands in a shell pipeline or two terminals are enough to + * do that. The file holds nothing: only its existence matters, so no token reaches the disk. + */ +export async function withRefreshLock(path, work) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + const deadline = Date.now() + WAIT_MS + for (;;) { + try { + await (await open(path, 'wx', 0o600)).close() + break + } catch (error) { + if (error.code !== 'EEXIST') throw error + } + if (Date.now() > deadline) { + throw new Error( + `Another command has held ${path} for too long. Delete it if none is running.`, + ) + } + // A process that was killed mid-refresh leaves the file behind. + const held = await stat(path).catch(() => undefined) + if (held && Date.now() - held.mtimeMs > STALE_MS) await rm(path, { force: true }) + await new Promise((resolve) => setTimeout(resolve, POLL_MS)) + } + try { + return await work() + } finally { + await rm(path, { force: true }) + } +} diff --git a/examples/native-cli/lib/token-store.mjs b/examples/native-cli/lib/token-store.mjs new file mode 100644 index 0000000..64835b7 --- /dev/null +++ b/examples/native-cli/lib/token-store.mjs @@ -0,0 +1,28 @@ +import { AsyncEntry } from '@napi-rs/keyring' + +const options = { linux: { store: 'secret-service' } } + +/** + * Tokens 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. + * + * Two entries, because Windows caps one credential at about 2.5 KB and a JWT can take most of that. + * A desktop app can keep its access token in memory. A CLI is a new process for every command, so + * without this entry every command would spend a refresh. + */ +export function createTokenStore({ service, account }) { + const refresh = new AsyncEntry(service, `${account} refresh`, options) + const access = new AsyncEntry(service, `${account} access`, options) + return { + getRefresh: () => refresh.getPassword(), + setRefresh: (token) => refresh.setPassword(token), + getAccess: async () => JSON.parse((await access.getPassword()) ?? 'null'), + setAccess: (value) => access.setPassword(JSON.stringify(value)), + delete: async () => { + await refresh.deleteCredential() + await access.deleteCredential() + }, + } +} diff --git a/examples/native-cli/package-lock.json b/examples/native-cli/package-lock.json new file mode 100644 index 0000000..7ecb749 --- /dev/null +++ b/examples/native-cli/package-lock.json @@ -0,0 +1,321 @@ +{ + "name": "seamless-native-cli-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seamless-native-cli-example", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/keyring": "2.1.1", + "openid-client": "6.8.9" + }, + "bin": { + "notes": "bin/notes.mjs" + }, + "devDependencies": { + "jose": "6.2.15", + "playwright": "1.64.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "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/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/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/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/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" + } + } + } +} diff --git a/examples/native-cli/package.json b/examples/native-cli/package.json new file mode 100644 index 0000000..5ff8fed --- /dev/null +++ b/examples/native-cli/package.json @@ -0,0 +1,26 @@ +{ + "name": "seamless-native-cli-example", + "private": true, + "version": "0.0.0", + "description": "A command line tool on the Seamless Auth OpenID Provider: loopback redirect with PKCE, the device grant, tokens in the OS keychain and no client secret.", + "license": "Apache-2.0", + "type": "module", + "bin": { + "notes": "bin/notes.mjs" + }, + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "notes": "node bin/notes.mjs", + "e2e": "node e2e/run.mjs" + }, + "dependencies": { + "@napi-rs/keyring": "2.1.1", + "openid-client": "6.8.9" + }, + "devDependencies": { + "jose": "6.2.15", + "playwright": "1.64.1" + } +} diff --git a/examples/native-cli/scripts/recipe-env.mjs b/examples/native-cli/scripts/recipe-env.mjs new file mode 100644 index 0000000..75e88ac --- /dev/null +++ b/examples/native-cli/scripts/recipe-env.mjs @@ -0,0 +1,17 @@ +// Prints the CLI's settings for a running local recipe, as shell exports: +// eval "$(node scripts/recipe-env.mjs /path/to/seamless-auth-oidc-provider/local)" +import { readRecipe } from './recipe.mjs' + +const [recipeDir] = process.argv.slice(2) +if (!recipeDir) { + console.error('Usage: node scripts/recipe-env.mjs ') + process.exit(1) +} +const { cli } = readRecipe(recipeDir) +if (!cli.OIDC_CLIENT_ID) { + console.error('Run scripts/register-client.mjs first.') + process.exit(1) +} +for (const [name, value] of Object.entries(cli)) { + console.log(`export ${name}='${value}'`) +} diff --git a/examples/native-cli/scripts/recipe.mjs b/examples/native-cli/scripts/recipe.mjs new file mode 100644 index 0000000..6ae6c1d --- /dev/null +++ b/examples/native-cli/scripts/recipe.mjs @@ -0,0 +1,35 @@ +import { existsSync, 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 clientFile = join(generated, 'notes-cli-client.json') + const clientId = existsSync(clientFile) + ? JSON.parse(readFileSync(clientFile, 'utf8')).clientId + : undefined + return { + generated, + clientFile, + recipe, + mailboxUrl: `http://127.0.0.1:${settings.MAILBOX_PORT}`, + appName: settings.APP_NAME, + // All the CLI gets: nothing secret, and no way to the auth API. + cli: { + OIDC_ISSUER: recipe.issuer, + OIDC_CLIENT_ID: clientId, + API_RESOURCE: recipe.resource, + API_SCOPES: recipe.scopes.join(' '), + // Only for this local CA. A deployed issuer has a certificate the OS already trusts. + NODE_EXTRA_CA_CERTS: join(generated, 'ca.crt'), + }, + } +} diff --git a/examples/native-cli/scripts/register-client.mjs b/examples/native-cli/scripts/register-client.mjs new file mode 100644 index 0000000..73df917 --- /dev/null +++ b/examples/native-cli/scripts/register-client.mjs @@ -0,0 +1,110 @@ +// Registers the CLI's own client, the way an operator would: the owner signs in with an email code, +// steps up with TOTP, and writes through the admin API. The recipe's seed registers a desktop client +// with no device grant, and this example needs one with both flows. Safe to rerun. +// node scripts/register-client.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/register-client.mjs ') + process.exit(1) +} +const { generated, clientFile, recipe, mailboxUrl, appName } = 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)) + +const RECORD = { + name: `${appName} command line tool`, + clientType: 'public', + firstParty: true, + tokenEndpointAuthMethod: 'none', + // RFC 8252: any port on the loopback IP. There is no secret, in either flow. + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + deviceFlowEnabled: true, +} + +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 { applications } = await must('GET', '/admin/applications', { token }) +const application = + applications.find((app) => app.name === RECORD.name) ?? + (await must('POST', '/admin/applications', { token, body: RECORD })).application +const { resources } = await must('GET', '/admin/resources', { token }) +const resource = resources.find((r) => r.identifier === recipe.resource) +await must('PUT', `/admin/applications/${application.id}/resources`, { + token, + body: { resources: [{ resourceId: resource.id, scopes: recipe.scopes }] }, +}) + +writeFileSync(clientFile, `${JSON.stringify({ clientId: application.id }, null, 2)}\n`) +console.log( + `${application.name} is ${application.id}, device flow ${application.deviceFlowEnabled}`, +) diff --git a/src/content/docs/build/native-apps-oidc.mdx b/src/content/docs/build/native-apps-oidc.mdx index c6c2770..cfcf0e4 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 (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.' +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 runnable Electron and CLI examples.' sidebar: order: 14 --- @@ -21,7 +21,9 @@ the registry and the token model. This page covers what is different for native Sign-in ships in v0.21 with the OpenID Provider, including sign-out and the device authorization grant for CLIs on machines without a browser. Part of [An App With Its Own Backend](#an-app-with-its-own-backend) is not released: account notices through a client link need - Seamless Auth API v0.23.0, which is not published yet. See [Versions](#versions). + Seamless Auth API v0.23.0, which is not published yet. See [Versions](#versions). So does `seamless + profile login` through the provider, described in [A Runnable CLI + Example](#a-runnable-cli-example). --- @@ -417,8 +419,7 @@ is an Electron app and an Express backend that do everything on this page agains 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. +[local recipe](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local). 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 @@ -429,13 +430,83 @@ it does and does not check. ## A Runnable CLI Example -