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
81 changes: 81 additions & 0 deletions .github/workflows/native-cli-example.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions examples/native-cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
e2e/screenshots/
142 changes: 142 additions & 0 deletions examples/native-cli/README.md
Original file line number Diff line number Diff line change
@@ -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 <id> --set <api-identifier>=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.
101 changes: 101 additions & 0 deletions examples/native-cli/bin/notes.mjs
Original file line number Diff line number Diff line change
@@ -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 <command>

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
}
26 changes: 26 additions & 0 deletions examples/native-cli/e2e/keychain.mjs
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions examples/native-cli/e2e/record-fetch.mjs
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading