diff --git a/README.md b/README.md index aadc915..46b3b8c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,15 @@ machine session; it does not replace a token exported into another shell. `logou removes this backend's local record and separately reports whether remote revocation completed. +A sign-in lasts as long as its refresh token: the backend ends it a fixed time after +sign-in (five days by default), however often it is renewed. `auth-status` reports that +moment as `sessionEndsAt` and names it in its summary. Once it has passed, the session +reads as signed out without contacting the backend, and every request answers "The saved +session for … ended on … UTC. Sign in again." A 401 means the backend no longer accepts +the login (expired, revoked, or another backend's): devtools renews once and otherwise asks +for a new sign-in. A 403 is a permission the account lacks: it is reported as `FORBIDDEN`, +with no renewal, no retry and no sign-in prompt. + A deliberately supplied `MAINSEQUENCE_ACCESS_TOKEN` with its matching `MAINSEQUENCE_ENDPOINT` is a process or CI identity. A rejected process identity never falls back to another saved identity. Runtime credentials are a separate application lane diff --git a/package-lock.json b/package-lock.json index f72b0e0..72cd5f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.14", + "version": "0.3.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.14", + "version": "0.3.15", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index 1836da1..01bdc90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.14", + "version": "0.3.15", "description": "Shared MainSequence developer actions, project operations, MCP client and command runner", "license": "MIT", "engines": { diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 38b2a39..fc19b70 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -1,6 +1,6 @@ import { McpConnections, McpRequest, createMcpConnectionCache, type McpConnectionCache } from './mcp'; import { AgentClient, AgentRequest, isAgentAction } from './agents'; -import { ActionRequest, ActionResult, HostServices, ProjectAction, SessionService, ToolingError } from './models'; +import { ActionRequest, ActionResult, HostServices, ProjectAction, SessionService, SessionStatus, ToolingError } from './models'; import { MachineSessionService } from './session'; import { listRepositories, projectContext, resolveBackend, resolveRepository } from './repositories'; import { DRY_RUN_ACTIONS, projectCapabilities, runProjectAction, setupRepository, withProjectLock } from './projects'; @@ -12,6 +12,12 @@ import { currentDirectory } from './workingDirectory'; import { createProjectToolCatalog, projectToolResult, type ProjectToolCatalogView, type ProjectToolScope } from './projectTools'; const UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i; +/** Names when the saved login ends, or ended, so a person reading the summary knows to sign in. */ +function authSummary(status: SessionStatus): string { + const at = status.sessionEndsAt ? status.sessionEndsAt.slice(0, 16).replace('T', ' ') + ' UTC' : ''; + if (status.authenticated) return at ? `The selected session is usable until ${at}.` : 'The selected session is usable.'; + return at && Date.parse(status.sessionEndsAt!) <= Date.now() ? `The saved session ended on ${at}. Sign in again.` : 'No usable session is selected.'; +} export function createDevTools(options: ConstructorParameters[1] & { host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; mcpConnectionsPath?: string; mcpCache?:McpConnectionCache; @@ -86,7 +92,7 @@ export function createDevTools(options: ConstructorParameters, 'platform MCP ' + backend) as T; if (response.status === 204) return undefined as T; @@ -181,6 +193,7 @@ export class MachineSessionService implements SessionService { const previous = await this.saved(backend); if (!previous?.refresh) throw new ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.'); if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount) throw this.accountChanged(); + if (ended(previous)) throw sessionEnded(backend, previous); // Another host process may have renewed while this one waited for the // shared lease. Reuse its saved pair; never rotate it a second time. if (stale && previous.access && previous.access !== stale.access) { @@ -228,23 +241,28 @@ export class MachineSessionService implements SessionService { let session = explicit || ((this.store || this.options.persistence) ? await this.saved(backend) : null); const source = explicit ? 'environment' : session ? (this.options.persistence?.mode ?? 'machine') : 'none'; let expiresAt = expiry(session?.access || ''); - let authenticated = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now()) || (!explicit && session?.refresh)); + // The end of the saved login is read from its refresh token, so an ended session is + // reported signed out without contacting the backend. A process identity has no renewal. + let sessionEndsAt = explicit ? undefined : expiry(session?.refresh || ''); + const usableAccess = Boolean(session?.access && (!expiresAt || Date.parse(expiresAt) > Date.now())); + let authenticated = explicit ? usableAccess : Boolean((usableAccess || session?.refresh) && !ended(session)); if (check) { - if (!authenticated) throw new ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.'); - if (authenticated) { - const profile = await this.request(backend, 'GET', '/api/v1/users/me/'); - const username = profile?.user?.username || profile?.username; - if (typeof username === 'string') session = { ...session!, username }; - authenticated = true; - expiresAt = expiry((explicit || await this.saved(backend))?.access || ''); - } + if (!authenticated) throw !explicit && session && ended(session) ? sessionEnded(backend, session, 'NOT_AUTHENTICATED') + : new ToolingError('NOT_AUTHENTICATED', 'No usable session is selected for this backend.'); + const profile = await this.request(backend, 'GET', '/api/v1/users/me/'); + const username = profile?.user?.username || profile?.username; + if (typeof username === 'string') session = { ...session!, username }; + authenticated = true; + const current = explicit || await this.saved(backend); + expiresAt = expiry(current?.access || ''); + sessionEndsAt = explicit ? undefined : expiry(current?.refresh || ''); } return { backend, authenticated, source, persistence: explicit ? 'environment' : this.options.persistence?.mode ?? (this.store ? 'machine' : 'unavailable'), store: this.options.persistence?.name ?? this.store?.name ?? null, ...(session?.username ? { username: session.username } : {}), - ...(expiresAt ? { expiresAt } : {}), ...(check ? { checked: true } : {}) + ...(expiresAt ? { expiresAt } : {}), ...(sessionEndsAt ? { sessionEndsAt } : {}), ...(check ? { checked: true } : {}) }; } async request(input: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe = method === 'GET', options?: { timeoutMs?: number }): Promise { @@ -289,7 +307,8 @@ export class MachineSessionService implements SessionService { const source = current.source === 'environment' ? 'environment' : this.options.persistence?.mode ?? 'machine'; return { backend, authenticated:true, source, persistence:source, store:source === 'environment' ? null : this.options.persistence?.name ?? this.store?.name ?? null, - username:expectedAccount, expiresAt:expiry(current.session.access), checked:true }; + username:expectedAccount, expiresAt:expiry(current.session.access), + ...(source !== 'environment' && expiry(current.session.refresh) ? {sessionEndsAt:expiry(current.session.refresh)} : {}), checked:true }; }, login: unsupported, logout: unsupported, refresh: unsupported, sanitize: text => this.sanitize(text), @@ -301,6 +320,7 @@ export class MachineSessionService implements SessionService { if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000)) throw new ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.'); const backend = requireBackend(input); let { session, source } = await this.selected(backend); + if (source === 'machine' && ended(session)) throw sessionEnded(backend, session); if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)!) <= Date.now() + 30_000)) { if (source === 'machine') session = await this.renew(backend, expectedAccount, session); else if (!session.access) throw new ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.'); @@ -342,7 +362,8 @@ export class MachineSessionService implements SessionService { } private async machineStatus(backend: string): Promise { const session = await this.saved(backend); - return { backend, authenticated: Boolean(session?.access || session?.refresh), source: session ? (this.options.persistence?.mode ?? 'machine') : 'none', persistence: this.options.persistence?.mode ?? 'machine', store: this.options.persistence?.name ?? this.store?.name ?? null, username: session?.username, expiresAt: expiry(session?.access || '') }; + const sessionEndsAt = expiry(session?.refresh || ''); + return { backend, authenticated: Boolean((session?.access || session?.refresh) && !ended(session)), source: session ? (this.options.persistence?.mode ?? 'machine') : 'none', persistence: this.options.persistence?.mode ?? 'machine', store: this.options.persistence?.name ?? this.store?.name ?? null, username: session?.username, expiresAt: expiry(session?.access || ''), ...(sessionEndsAt ? { sessionEndsAt } : {}) }; } async logout(input: string): Promise<{ revoked: boolean }> { const backend = requireBackend(input); diff --git a/tests/mcp.test.cjs b/tests/mcp.test.cjs index 5aa00b2..3ecd22c 100644 --- a/tests/mcp.test.cjs +++ b/tests/mcp.test.cjs @@ -413,10 +413,10 @@ test('nameless release details still validate identity and all required MCP meta assert.equal(await fs.readFile(f.file, 'utf8'), saved); }); -test('revocation, removed capabilities, changed URLs, and a wrong credential target stop before project execution', async t => { +test('withdrawn access, removed capabilities, changed URLs, and a wrong credential target stop before project execution', async t => { const f = await fixture(t); await f.dispatch('mcp-add'); f.state.denied = true; - assert.equal((await f.dispatch('mcp-tools')).error.code, 'AUTH_REJECTED'); + assert.equal((await f.dispatch('mcp-tools')).error.code, 'FORBIDDEN'); f.state.denied = false; f.state.noMcp = true; assert.equal((await f.dispatch('mcp-tools')).error.code, 'API_ERROR'); f.state.noMcp = false; f.state.endpoint = 'https://changed.example/mcp'; diff --git a/tests/session.test.cjs b/tests/session.test.cjs index 7b385e3..90ac635 100644 --- a/tests/session.test.cjs +++ b/tests/session.test.cjs @@ -142,20 +142,67 @@ test('login obtains a missing username from the existing profile endpoint', asyn assert.equal(result.status, 'success'); assert.equal(result.data.username, 'José'); assert.equal(readSession(store, backend).username, 'José'); }); -test('a refused renewal names the saved session, its expiry and the sign-in, and keeps the session', async t => { - const { backend } = await fixtureBackend(t); - const part = value => Buffer.from(JSON.stringify(value)).toString('base64url'); - const expiredRefresh = `${part({ alg: 'none' })}.${part({ exp: Math.floor(Date.now() / 1000) - 3600 })}.signature`; - const store = memoryStore(); saveSession(store, backend, { username: 'shared', access: 'stale-access', refresh: expiredRefresh }); +const jwtPart = value => Buffer.from(JSON.stringify(value)).toString('base64url'); +/** An unsigned token carrying only `exp`, seconds from now; the client reads the claim, never the signature. */ +const tokenEnding = seconds => `${jwtPart({ alg: 'none' })}.${jwtPart({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`; + +test('an ended saved session is signed out without contacting the backend, and is kept for the sign-in', async t => { + const { backend, calls } = await fixtureBackend(t); + const endedRefresh = tokenEnding(-3600); + const store = memoryStore(); saveSession(store, backend, { username: 'shared', access: tokenEnding(-3000), refresh: endedRefresh }); + const tooling = createDevTools({ host: createNodeHost(), env: {}, store }); + const status = await tooling.dispatch({ action: 'auth-status', backend }); + assert.equal(status.status, 'success'); assert.equal(status.data.authenticated, false); assert.equal(status.data.username, 'shared'); + assert.equal(Date.parse(status.data.sessionEndsAt), (Math.floor(Date.now() / 1000) - 3600) * 1000); + assert.match(status.summary, /^The saved session ended on \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\. Sign in again\.$/); + const ended = new RegExp(`^The saved session for ${backend.replace(/[.]/g, '\\.')} ended on \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} UTC\\. Sign in again\\.$`); + const checked = await tooling.dispatch({ action: 'auth-status', backend, check: true }); + assert.equal(checked.error.code, 'NOT_AUTHENTICATED'); assert.match(checked.error.message, ended); + for (const action of ['repositories', 'refresh-token']) { + const result = await tooling.dispatch({ action, backend }); + assert.equal(result.error.code, 'AUTH_REJECTED'); assert.match(result.error.message, ended); + } + // An access token still inside its five minutes does not outlive the login it belongs to. + saveSession(store, backend, { username: 'shared', access: tokenEnding(240), refresh: endedRefresh }); + assert.equal((await tooling.dispatch({ action: 'auth-status', backend })).data.authenticated, false); + assert.equal((await tooling.dispatch({ action: 'repositories', backend })).error.code, 'AUTH_REJECTED'); + assert.equal(calls.length, 0); + assert.equal(readSession(store, backend).refresh, endedRefresh); +}); + +test('a live session names when it ends; a revoked one is refused by the backend and says so', async t => { + const { backend, calls } = await fixtureBackend(t); + const refresh = tokenEnding(4 * 86400), store = memoryStore(); + saveSession(store, backend, { username: 'shared', access: 'stale-access', refresh }); + const status = await createDevTools({ host: createNodeHost(), env: {}, store }).dispatch({ action: 'auth-status', backend }); + assert.equal(status.data.authenticated, true); + assert.equal(Date.parse(status.data.sessionEndsAt), (Math.floor(Date.now() / 1000) + 4 * 86400) * 1000); + assert.match(status.summary, /^The selected session is usable until \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\.$/); const fetchImpl = async (url, init) => String(url).endsWith('/auth/jwt-token/token/refresh/') - ? new Response(JSON.stringify({ detail: 'Token is invalid or expired' }), { status: 401 }) + ? new Response(JSON.stringify({ detail: 'Token is blacklisted' }), { status: 401 }) : fetch(url, init); const result = await createDevTools({ host: createNodeHost(), env: {}, store, fetch: fetchImpl }).dispatch({ action: 'repositories', backend }); assert.equal(result.error.code, 'AUTH_REJECTED'); - assert.match(result.error.message, /^The backend refused the saved session for http:\/\/127\.0\.0\.1:\d+: it expired on \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\. Sign in again\.$/); - assert.equal(readSession(store, backend).refresh, expiredRefresh); + assert.match(result.error.message, /^The backend refused the saved session for http:\/\/127\.0\.0\.1:\d+: it was revoked or belongs to another backend\. Sign in again\.$/); + assert.equal(readSession(store, backend).refresh, refresh); assert.equal(calls.length, 1); }); +test('403 is a missing permission: no renewal, no retry and no sign-in prompt', async t => { + const { backend, calls } = await fixtureBackend(t); + const store = memoryStore(); saveSession(store, backend, { username: 'shared', access: 'fixture-access-1', refresh: 'fixture-refresh-1' }); + const fetchImpl = async (url, init) => new URL(url).pathname === '/api/v1/code-repositories/' + ? (calls.push({ path: '/api/v1/code-repositories/' }), new Response(JSON.stringify({ detail: 'You do not have permission to perform this action.' }), { status: 403 })) + : fetch(url, init); + const result = await createDevTools({ host: createNodeHost(), env: {}, store, fetch: fetchImpl }).dispatch({ action: 'repositories', backend }); + assert.equal(result.error.code, 'FORBIDDEN'); + assert.match(result.error.message, /^The backend http:\/\/127\.0\.0\.1:\d+ denied GET \/api\/v1\/code-repositories\/ \(HTTP 403\): the signed-in account does not have permission for it\.$/); + assert.deepEqual(calls.map(call => call.path), ['/api/v1/code-repositories/']); + assert.equal(readSession(store, backend).access, 'fixture-access-1'); + const { readMcpResponse } = require('../dist/shared/mcpTransport.js'); + for (const [status, code] of [[401, 'AUTH_REJECTED'], [403, 'FORBIDDEN'], [500, 'API_ERROR']]) { + await assert.rejects(readMcpResponse(new Response('{}', { status }), { jsonrpc: '2.0', id: 1, method: 'tools/list' }, 'project MCP https://crm.example'), error => error.code === code); + } +}); test('per-request timeout accommodates the advertised agent deadline and rejects values beyond the cap before HTTP', async t => { const { backend, calls } = await fixtureBackend(t);