From 22f01f0e2620f22e1a345aec5e2ace978be26280 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 18 Jun 2026 14:09:29 +0100 Subject: [PATCH 1/4] Initial refactor of httpTokens end points --- forge/db/controllers/AccessToken.js | 31 ++++++++++++---- forge/db/models/AccessToken.js | 11 ++++++ forge/ee/routes/httpTokens/index.js | 56 +++++++++++++++++++++++++---- forge/ee/routes/index.js | 2 +- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/forge/db/controllers/AccessToken.js b/forge/db/controllers/AccessToken.js index 8141845e7c..bc2bb3cd9a 100644 --- a/forge/db/controllers/AccessToken.js +++ b/forge/db/controllers/AccessToken.js @@ -300,25 +300,42 @@ module.exports = { }, // Should these only get added via forge/ee/lib/httpTokens? - createHTTPNodeToken: async function (app, project, name, scope = [''], expiresAt) { - const projectId = (project && typeof project === 'object') ? project.id : project + createHTTPNodeToken: async function (app, owner, name, scope = [''], expiresAt) { + // Ensure a string + const ownerId = '' + owner.id + let ownerType + if (owner.constructor.name === 'Project') { + ownerType = 'http' + } else if (owner.constructor.name === 'Device') { + ownerType = 'http:device' + } else { + throw new Error('Invalid owner type for HTTP Node Token: ' + owner.constructor.name) + } const token = generateToken(32, 'ffhttp') const tok = await app.db.models.AccessToken.create({ token, expiresAt, name, scope, - ownerId: projectId, - ownerType: 'http' + ownerId, + ownerType }) // Overwrite the hashed token with the plain value const result = app.db.views.AccessToken.instanceHTTPTokenSummary(tok) result.token = token return result }, - updateHTTPNodeToken: async function (app, project, tokenId, scope = [''], expiresAt) { - const projectId = (project && typeof project === 'object') ? project.id : project - const token = await app.db.models.AccessToken.byId(tokenId, 'http', projectId) + updateHTTPNodeToken: async function (app, owner, tokenId, scope = [''], expiresAt) { + const ownerId = '' + owner.id + let ownerType + if (owner.constructor.name === 'Project') { + ownerType = 'http' + } else if (owner.constructor.name === 'Device') { + ownerType = 'http:device' + } else { + throw new Error('Invalid owner type for HTTP Node Token: ' + owner.constructor.name) + } + const token = await app.db.models.AccessToken.byId(tokenId, ownerType, ownerId) if (token) { token.scope = scope if (expiresAt === undefined) { diff --git a/forge/db/models/AccessToken.js b/forge/db/models/AccessToken.js index 5d4aea7f4a..bd43f63a52 100644 --- a/forge/db/models/AccessToken.js +++ b/forge/db/models/AccessToken.js @@ -126,6 +126,17 @@ module.exports = { attributes: ['id', 'name', 'scope', 'expiresAt'] }) return tokens + }, + getDeviceHTTPTokens: async (device) => { + const tokens = this.findAll({ + where: { + ownerType: 'http:device', + ownerId: '' + device.id + }, + order: [['id', 'ASC']], + attributes: ['id', 'name', 'scope', 'expiresAt'] + }) + return tokens } }, instance: { diff --git a/forge/ee/routes/httpTokens/index.js b/forge/ee/routes/httpTokens/index.js index f4b5dfb9e0..5b481c2333 100644 --- a/forge/ee/routes/httpTokens/index.js +++ b/forge/ee/routes/httpTokens/index.js @@ -3,6 +3,8 @@ module.exports = async function (app) { app.addHook('preHandler', app.verifySession) app.addHook('preHandler', async (request, reply) => { + // This route is either under `/projects/:projectId/httpTokens' or '/devices/:deviceId/httpTokens' + // The preHandler needs to handle both cases. if (request.params.projectId !== undefined) { if (request.params.projectId) { try { @@ -34,12 +36,40 @@ module.exports = async function (app) { reply.code(404).send({ code: 'not_found', error: 'Not Found' }) } } + if (request.params.deviceId !== undefined) { + if (request.params.deviceId) { + try { + request.device = await app.db.models.Device.byId(request.params.deviceId) + if (!request.device) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return + } + await request.device.Team.ensureTeamTypeExists() + if (!request.device.Team.getFeatureProperty('teamHttpSecurity', false)) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + if (request.session.User) { + request.teamMembership = await request.session.User.getTeamMembership(request.device.Team.id) + if (!request.teamMembership && !request.session.User.admin) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + } else if (request.session.ownerId !== request.params.deviceId) { + // AccessToken being used - but not owned by this device + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return // eslint-disable-line no-useless-return + } + } catch (err) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } else { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } }) - app.get('/', { - preHandler: app.needsPermission('project:edit') - }, async (request, reply) => { - const tokens = await app.db.models.AccessToken.getProjectHTTPTokens(request.project) + function getTokens (request, reply, tokens) { // exclude FF-Expert auto generated HTTP MCP tokens from listing const withoutExpertMcpTokens = tokens.filter(token => !isExpertMcpToken(token)) const tokensView = app.db.views.AccessToken.instanceHTTPTokenSummaryList(withoutExpertMcpTokens) @@ -47,9 +77,21 @@ module.exports = async function (app) { tokens: tokensView, count: tokens.length }) + } + app.get('/projects/:projectId/httpTokens', { + preHandler: app.needsPermission('project:edit') + }, async (request, reply) => { + const tokens = await app.db.models.AccessToken.getProjectHTTPTokens(request.project) + getTokens(request, reply, tokens) + }) + app.get('/devices/:deviceId/httpTokens', { + preHandler: app.needsPermission('device:edit') + }, async (request, reply) => { + const tokens = await app.db.models.AccessToken.getDeviceHTTPTokens(request.device) + getTokens(request, reply, tokens) }) - app.post('/', { + app.post('/projects/:projectId/httpTokens', { preHandler: app.needsPermission('project:edit') }, async (request, reply) => { try { @@ -68,7 +110,7 @@ module.exports = async function (app) { } }) - app.put('/:id', { + app.put('/projects/:projectId/httpTokens/:id', { preHandler: app.needsPermission('project:edit', true) }, async (request, reply) => { try { @@ -93,7 +135,7 @@ module.exports = async function (app) { } }) - app.delete('/:id', { + app.delete('/projects/:projectId/httpTokens/:id', { preHandler: app.needsPermission('project:edit') }, async (request, reply) => { try { diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 421b3b1545..ba9f1b6f98 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -24,7 +24,7 @@ module.exports = async function (app) { await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http }) await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http }) - await app.register(require('./httpTokens'), { prefix: '/api/v1/projects/:projectId/httpTokens', logLevel: app.config.logging.http }) + await app.register(require('./httpTokens'), { prefix: '/api/v1', logLevel: app.config.logging.http }) await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http }) await app.register(require('./staticAssets'), { prefix: '/api/v1/projects/:projectId/files', logLevel: app.config.logging.http }) await app.register(require('./projectHistory'), { prefix: '/api/v1/projects/:instanceId/history', logLevel: app.config.logging.http }) From 29c14a621655df75b0cfc09443e2d55460bce6b3 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 19 Jun 2026 11:08:40 +0100 Subject: [PATCH 2/4] Complete crud api for device http bearer tokens --- forge/auditLog/device.js | 12 +++ forge/ee/routes/httpTokens/index.js | 95 +++++++++++++++---- forge/routes/auth/oauth.js | 2 + .../forge/ee/routes/httpTokens/index_spec.js | 86 ++++++++++++++++- 4 files changed, 177 insertions(+), 18 deletions(-) diff --git a/forge/auditLog/device.js b/forge/auditLog/device.js index 038735286a..4a084375a9 100644 --- a/forge/auditLog/device.js +++ b/forge/auditLog/device.js @@ -107,7 +107,19 @@ module.exports = { async targetSet (actionedBy, error, device, snapshot) { await log('device.snapshot.target-set', actionedBy, device?.id, generateBody({ error, device, snapshot })) } + }, + httpToken: { + async created (actionedBy, error, device, token) { + await log('device.httpToken.created', actionedBy, device?.id, generateBody({ error, device, token })) + }, + async updated (actionedBy, error, device, updates) { + await log('device.httpToken.updated', actionedBy, device?.id, generateBody({ error, device, updates })) + }, + async deleted (actionedBy, error, device, token) { + await log('device.httpToken.deleted', actionedBy, device?.id, generateBody({ error, device, token })) + } } + } const log = async (event, actionedBy, deviceId, body) => { diff --git a/forge/ee/routes/httpTokens/index.js b/forge/ee/routes/httpTokens/index.js index 5b481c2333..ec7ad93d2f 100644 --- a/forge/ee/routes/httpTokens/index.js +++ b/forge/ee/routes/httpTokens/index.js @@ -69,6 +69,8 @@ module.exports = async function (app) { } }) + // #region GET /httpTokens + function getTokens (request, reply, tokens) { // exclude FF-Expert auto generated HTTP MCP tokens from listing const withoutExpertMcpTokens = tokens.filter(token => !isExpertMcpToken(token)) @@ -91,40 +93,64 @@ module.exports = async function (app) { getTokens(request, reply, tokens) }) - app.post('/projects/:projectId/httpTokens', { - preHandler: app.needsPermission('project:edit') - }, async (request, reply) => { + // #region POST /httpTokens + + async function createToken (request, reply) { try { const body = request.body // Prevent creation of Expert MCP Access Tokens via this route if (isExpertMcpToken({ scope: body.scope })) { throw new Error('Cannot create Expert MCP Access Token via this route') } - const token = await app.db.controllers.AccessToken.createHTTPNodeToken(request.project, body.name, [''], body.expiresAt) - // token has already been sanitised via views.AccessToken.instanceHTTPTokenSummary - await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) + const token = await app.db.controllers.AccessToken.createHTTPNodeToken(request.project || request.device, body.name, [''], body.expiresAt) + if (request.project) { + await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.created(request.session.User, null, request.device, body) + } reply.send(token || {}) } catch (err) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + app.post('/projects/:projectId/httpTokens', { + preHandler: app.needsPermission('project:edit') + }, async (request, reply) => { + return createToken(request, reply) }) - - app.put('/projects/:projectId/httpTokens/:id', { - preHandler: app.needsPermission('project:edit', true) + app.post('/devices/:deviceId/httpTokens', { + preHandler: app.needsPermission('device:edit') }, async (request, reply) => { + return createToken(request, reply) + }) + + // #region PUT /httpTokens/:id + + async function updateToken (request, reply) { try { - const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'http', request.project.id) + const owner = request.project || request.device + // Ensure id is a string as the AccessToken.ownerId is stored as a string in the database + const ownerId = '' + owner.id + let ownerType = 'http' + if (request.device) { + ownerType = 'http:device' + } + const oldToken = await app.db.models.AccessToken.byId(request.params.id, ownerType, ownerId) if (oldToken) { // Prevent modification of Expert MCP Access Tokens via this route if (isExpertMcpToken(oldToken)) { throw new Error('Cannot modify Expert MCP Access Token') } const body = request.body - const token = await app.db.controllers.AccessToken.updateHTTPNodeToken(request.project, request.params.id, [''], body.expiresAt) + const token = await app.db.controllers.AccessToken.updateHTTPNodeToken(owner, request.params.id, [''], body.expiresAt) const updates = new app.auditLog.formatters.UpdatesCollection() updates.pushDifferences({ expiresAt: oldToken.expiresAt, scope: oldToken.scope.join(',') }, { expiresAt: body.expiresAt, scope: body.scope }) - await app.auditLog.Project.project.httpToken.updated(request.session.User, null, request.project, updates) + if (request.project) { + await app.auditLog.Project.project.httpToken.updated(request.session.User, null, request.project, updates) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.updated(request.session.User, null, request.device, updates) + } reply.send(app.db.views.AccessToken.instanceHTTPTokenSummary(token)) return } @@ -133,16 +159,40 @@ module.exports = async function (app) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + + app.put('/projects/:projectId/httpTokens/:id', { + preHandler: app.needsPermission('project:edit', true) + }, async (request, reply) => { + return updateToken(request, reply) }) - app.delete('/projects/:projectId/httpTokens/:id', { - preHandler: app.needsPermission('project:edit') + app.put('/devices/:deviceId/httpTokens/:id', { + preHandler: app.needsPermission('device:edit', true) }, async (request, reply) => { + return updateToken(request, reply) + }) + + // #region DELETE /httpTokens/:id + + async function deleteToken (request, reply) { try { - const oldToken = await app.db.models.AccessToken.byId(request.params.id, 'http', request.project.id) + const owner = request.project || request.device + // Ensure id is a string as the AccessToken.ownerId is stored as a string in the database + const ownerId = '' + owner.id + let ownerType = 'http' + if (request.device) { + ownerType = 'http:device' + } + + const oldToken = await app.db.models.AccessToken.byId(request.params.id, ownerType, ownerId) if (oldToken) { await oldToken.destroy() - await app.auditLog.Project.project.httpToken.deleted(request.session.User, null, request.project, { name: oldToken.name }) + if (request.project) { + await app.auditLog.Project.project.httpToken.deleted(request.session.User, null, request.project, { name: oldToken.name }) + } else if (request.device) { + await app.auditLog.Device.device.httpToken.deleted(request.session.User, null, request.device, { name: oldToken.name }) + } reply.code(201).send() return } @@ -151,7 +201,20 @@ module.exports = async function (app) { const resp = { code: 'unexpected_error', error: err.toString() } reply.code(400).send(resp) } + } + + app.delete('/projects/:projectId/httpTokens/:id', { + preHandler: app.needsPermission('project:edit') + }, async (request, reply) => { + return deleteToken(request, reply) }) + app.delete('/devices/:deviceId/httpTokens/:id', { + preHandler: app.needsPermission('device:edit') + }, async (request, reply) => { + return deleteToken(request, reply) + }) + + // #region Utility functions function isExpertMcpToken (token) { if (!token || !token.scope) { diff --git a/forge/routes/auth/oauth.js b/forge/routes/auth/oauth.js index 19e3eb5d4c..df128ee715 100644 --- a/forge/routes/auth/oauth.js +++ b/forge/routes/auth/oauth.js @@ -509,6 +509,8 @@ module.exports = async function (app) { // allow lowercase usernames for npm when publishing nodes to Team Library if (request.session.ownerType === 'npm' && request.session.scope.includes('team:packages:manage')) { sesOwnerId = sesOwnerId.toLowerCase() + } else if (request.session.ownerType === 'http:device') { + sesOwnerId = app.db.models.Device.encodeHashid(sesOwnerId) } if (request.params.ownerType === request.session.ownerType && request.params.ownerId === sesOwnerId) { let response diff --git a/test/unit/forge/ee/routes/httpTokens/index_spec.js b/test/unit/forge/ee/routes/httpTokens/index_spec.js index 4824a24866..a2182c43f9 100644 --- a/test/unit/forge/ee/routes/httpTokens/index_spec.js +++ b/test/unit/forge/ee/routes/httpTokens/index_spec.js @@ -23,6 +23,7 @@ describe('NR HTTP Bearer Tokens', function () { app.defaultTeamType.properties = defaultTeamTypeProperties await app.defaultTeamType.save() + TestObjects.alice = await app.db.models.User.byUsername('alice') TestObjects.BTeam = await app.db.models.Team.create({ name: 'BTeam', TeamTypeId: app.defaultTeamType.id }) await TestObjects.BTeam.addUser(TestObjects.alice, { through: { role: Roles.Owner } }) await app.factory.createSubscription(TestObjects.BTeam) @@ -33,7 +34,6 @@ describe('NR HTTP Bearer Tokens', function () { email: 'bob@example.com', password: 'bbPassword' }) - await TestObjects.BTeam.addUser(userBob, { through: { role: Roles.Member } }) TestObjects.application = await app.db.models.Application.create({ @@ -56,6 +56,20 @@ describe('NR HTTP Bearer Tokens', function () { cookies: { sid: TestObjects.tokens.alice } }) TestObjects.project = await app.db.models.Project.byId(JSON.parse(response.body).id) + + // Create a new device + const deviceResponse = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'test-device-1', + type: 'test-type', + team: TestObjects.BTeam.hashid + }, + cookies: { sid: TestObjects.tokens.alice } + }) + TestObjects.device = await app.db.models.Device.byId(JSON.parse(deviceResponse.body).id) + // Ensure the project is started await sleep(START_DELAY) }) @@ -77,7 +91,7 @@ describe('NR HTTP Bearer Tokens', function () { TestObjects.tokens[username] = response.cookies[0].value } - it('create HTTP token', async function () { + it('create HTTP token - instance', async function () { const response = await app.inject({ method: 'POST', url: `/api/v1/projects/${TestObjects.project.id}/httpTokens`, @@ -145,6 +159,74 @@ describe('NR HTTP Bearer Tokens', function () { authFailResponse.statusCode.should.equal(401) }) + it('create HTTP token - device', async function () { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens`, + payload: { + name: 'foo-device', + scope: '' + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const body = await response.json() + + body.should.have.property('token') + body.should.have.property('id') + ;(typeof body.id).should.equal('string') + const token = body.token + + const authResponse = await app.inject({ + method: 'GET', + url: `/account/check/http:device/${TestObjects.device.hashid}`, + headers: { + authorization: `Bearer ${token}` + } + }) + authResponse.statusCode.should.equal(200) + + let authFailResponse = await app.inject({ + method: 'GET', + url: `/account/check/http:device/${TestObjects.device.hashid}`, + headers: { + authorization: 'Bearer foo' + } + }) + authFailResponse.statusCode.should.equal(401) + + const dayAfterTomorrow = Date.now() + (48 * 60 * 60 * 10000) + const modifyResponse = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens/${body.id}`, + payload: { + scope: '', + expiresAt: dayAfterTomorrow + }, + cookies: { sid: TestObjects.tokens.alice } + }) + modifyResponse.statusCode.should.equal(200) + const modifiedToken = modifyResponse.json() + modifiedToken.should.not.have.property('token') + modifiedToken.should.have.property('expiresAt', new Date(dayAfterTomorrow).toISOString()) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens/${body.id}`, + cookies: { sid: TestObjects.tokens.alice } + }) + deleteResponse.statusCode.should.equal(201) + + authFailResponse = await app.inject({ + method: 'GET', + url: `/account/check/http:device/${TestObjects.device.hashid}`, + headers: { + authorization: `Bearer ${token}` + } + }) + authFailResponse.statusCode.should.equal(401) + }) + it('cannot create Expert MCP HTTP token via API', async function () { const response = await app.inject({ method: 'POST', From b8f853c78f3209f74ee50165461ef6daa38ec18f Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 22 Jun 2026 10:45:14 +0100 Subject: [PATCH 3/4] Add cross-team tests for device tokens --- .../forge/ee/routes/httpTokens/index_spec.js | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/test/unit/forge/ee/routes/httpTokens/index_spec.js b/test/unit/forge/ee/routes/httpTokens/index_spec.js index a2182c43f9..d28acd6fc1 100644 --- a/test/unit/forge/ee/routes/httpTokens/index_spec.js +++ b/test/unit/forge/ee/routes/httpTokens/index_spec.js @@ -277,7 +277,7 @@ describe('NR HTTP Bearer Tokens', function () { body.tokens[0].name.should.equal('other') }) - it('non-team owner cannot modify/delete token', async function () { + it('non-team owner cannot modify/delete token - instance', async function () { await login('bob', 'bbPassword') const response = await app.inject({ @@ -303,7 +303,7 @@ describe('NR HTTP Bearer Tokens', function () { }) modifyResponse1.statusCode.should.equal(403) - // Verify bob (team-member) cannot modify + // Verify bob (team-member) cannot delete const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/v1/projects/${TestObjects.project.id}/httpTokens/${token.id}`, @@ -311,8 +311,42 @@ describe('NR HTTP Bearer Tokens', function () { }) deleteResponse.statusCode.should.equal(403) }) + it('non-team owner cannot modify/delete token - device', async function () { + await login('bob', 'bbPassword') - it('cannot modify/delete token across-teams', async function () { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens`, + payload: { + name: 'foo-device', + scope: '' + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const token = await response.json() + + // Verify bob (team-member) cannot modify + const modifyResponse1 = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens/${token.id}`, + payload: { + name: 'foo' + }, + cookies: { sid: TestObjects.tokens.bob } + }) + modifyResponse1.statusCode.should.equal(403) + + // Verify bob (team-member) cannot delete + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens/${token.id}`, + cookies: { sid: TestObjects.tokens.bob } + }) + deleteResponse.statusCode.should.equal(403) + }) + + it('cannot modify/delete token across-teams - instance', async function () { // Create a new instance const instanceResponse = await app.inject({ method: 'POST', @@ -360,4 +394,50 @@ describe('NR HTTP Bearer Tokens', function () { }) deleteResponse.statusCode.should.equal(404) }) + it('cannot modify/delete token across-teams - device', async function () { + // Create a new instance + const deviceResponse = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'test-device-1', + type: 'test-type', + team: TestObjects.BTeam.hashid + }, + cookies: { sid: TestObjects.tokens.alice } + }) + const device2 = deviceResponse.json() + + // Create token for instance 1 + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${TestObjects.device.hashid}/httpTokens`, + payload: { + name: 'foo-device', + scope: '' + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const token = await response.json() + + // Verify cannot modify when referenced under instance2 + const modifyResponse1 = await app.inject({ + method: 'PUT', + url: `/api/v1/devices/${device2.id}/httpTokens/${token.id}`, + payload: { + name: 'foo' + }, + cookies: { sid: TestObjects.tokens.alice } + }) + modifyResponse1.statusCode.should.equal(404) + + // Verify cannot delete when referenced under instance2 + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/devices/${device2.id}/httpTokens/${token.id}`, + cookies: { sid: TestObjects.tokens.alice } + }) + deleteResponse.statusCode.should.equal(404) + }) }) From 95f1b06ff9f6745a99ca2edafd6ada34f31bdc22 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 22 Jun 2026 11:36:56 +0100 Subject: [PATCH 4/4] UI for device HTTP Tokens --- frontend/src/api/devices.js | 31 +++- .../src/pages/device/Settings/Security.vue | 109 +++++++++++- .../device/Settings/dialogs/TokenDialog.vue | 156 ++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/device/Settings/dialogs/TokenDialog.vue diff --git a/frontend/src/api/devices.js b/frontend/src/api/devices.js index f697fbcc7e..ae540423ab 100644 --- a/frontend/src/api/devices.js +++ b/frontend/src/api/devices.js @@ -246,6 +246,31 @@ const getDeviceEditorProxy = async (editorUrl) => { return client.get(editorUrl) } +const getHTTPTokens = async (deviceId) => { + return client.get(`/api/v1/devices/${deviceId}/httpTokens`).then(res => res.data) +} + +const createHTTPToken = async (deviceId, name, scope, expiresAt) => { + const data = { + name, + scope, + expiresAt + } + return client.post(`/api/v1/devices/${deviceId}/httpTokens`, data).then(res => res.data) +} + +const updateHTTPToken = async (deviceId, tokenId, scope, expiresAt) => { + const data = { + scope, + expiresAt + } + return client.put(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`, data).then(res => res.data) +} + +const deleteHTTPToken = async (deviceId, tokenId) => { + return client.delete(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`) +} + export default { create, getDevice, @@ -271,5 +296,9 @@ export default { restartDevice, startDevice, generateSnapshotDescription, - getDeviceEditorProxy + getDeviceEditorProxy, + getHTTPTokens, + createHTTPToken, + updateHTTPToken, + deleteHTTPToken } diff --git a/frontend/src/pages/device/Settings/Security.vue b/frontend/src/pages/device/Settings/Security.vue index 78a87a80ad..2f344ae2a0 100644 --- a/frontend/src/pages/device/Settings/Security.vue +++ b/frontend/src/pages/device/Settings/Security.vue @@ -13,34 +13,84 @@ :device="device" :team="team" /> +
+ HTTP Node Bearer Tokens +
+
+ + + + + +
+
+ HTTP Token support requires Device Agent v4.0 or later. +
+
+
+
- These options require Device Agent v3.1 or later to take effect. + These options require Device Agent v3.1 or later.
Save Settings
+ +