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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test shell session credential isolation
run: node --test test/*.test.cjs

- uses: actions/setup-go@v5
with:
go-version: '1.26'
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ adminui.Handler(adminui.ShellOptions{
Use `AuthModeSession` when the host authenticates admin APIs with same-origin
cookies. The default bearer-token mode preserves the standalone plugin shell
behavior.
Session mode ignores stored bearer tokens and does not forward them to APIs or
embedded tools, or change bearer-token storage. The host must authenticate and
authorize the shell, each contributed tool, and every API request.

## Host Conformance

Expand Down
3 changes: 3 additions & 0 deletions internal/ui_dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ <h2>Management pages</h2>
let currentGrantedScopes = [];

function storedToken() {
if (!requiresBearerToken) return '';
try {
return window.localStorage.getItem(tokenStorageKey) || '';
} catch (_) {
Expand All @@ -380,6 +381,7 @@ <h2>Management pages</h2>
}

function storeToken(token) {
if (!requiresBearerToken) return;
try {
window.localStorage.setItem(tokenStorageKey, token);
} catch (_) {
Expand All @@ -388,6 +390,7 @@ <h2>Management pages</h2>
}

function clearToken() {
if (!requiresBearerToken) return;
try {
window.localStorage.removeItem(tokenStorageKey);
} catch (_) {
Expand Down
48 changes: 48 additions & 0 deletions test/shell-session.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const vm = require('node:vm')

const html = fs.readFileSync(path.join(__dirname, '../internal/ui_dist/index.html'), 'utf8')
// Execute the shipped storage/header functions. This checks browser effects,
// rather than asserting the implementation contains a particular guard string.
const start = html.indexOf(' function storedToken() {')
const end = html.indexOf(' function scopeStrings(', start)
assert.ok(start >= 0 && end > start)
const functions = html.slice(start, end)

function shell(mode, stored = 'fixture-stale-bearer') {
const effects = []
const storage = new Map([['workflow.admin.token', stored]])
const context = vm.createContext({
window: { localStorage: {
getItem(key) { effects.push('read'); return storage.get(key) },
setItem(key, value) { effects.push('write'); storage.set(key, value) },
removeItem(key) { effects.push('remove'); storage.delete(key) },
} },
})
vm.runInContext(`const requiresBearerToken = ${JSON.stringify(mode)} !== 'session'; const tokenStorageKey = 'workflow.admin.token';\n${functions}`, context)
return { run: code => vm.runInContext(code, context), effects, storage }
}

test('session APIs and embedded bridges cannot receive a stale bearer token', () => {
const session = shell('session')
assert.equal(session.run('storedToken()'), '')
assert.equal(session.run('authHeaders({accept: "application/json"}).Authorization'), undefined)
assert.equal(session.run('authHeaders({accept: "application/json"}).accept'), 'application/json')
session.run('storeToken("fixture-new"); clearToken()')
assert.deepEqual(session.effects, [])
assert.equal(session.storage.get('workflow.admin.token'), 'fixture-stale-bearer')
})

test('bearer and unknown modes retain the protected bearer behavior', () => {
for (const mode of ['bearer', 'invalid', undefined]) {
const bearer = shell(mode)
assert.equal(bearer.run('authHeaders().Authorization'), 'Bearer fixture-stale-bearer')
bearer.run('storeToken("fixture-new")')
assert.equal(bearer.run('storedToken()'), 'fixture-new')
bearer.run('clearToken()')
assert.equal(bearer.storage.size, 0)
}
})
Loading