diff --git a/forge/db/migrations/20260727-01-add-asyncloginsession.js b/forge/db/migrations/20260727-01-add-asyncloginsession.js
new file mode 100644
index 0000000000..93714c1cba
--- /dev/null
+++ b/forge/db/migrations/20260727-01-add-asyncloginsession.js
@@ -0,0 +1,36 @@
+const { DataTypes } = require('sequelize')
+
+module.exports = {
+ /**
+ * upgrade database
+ * @param {QueryInterface} context Sequelize.QueryInterface
+ */
+ up: async (context, Sequelize) => {
+ await context.createTable('AsyncLoginSessions', {
+ sessionToken: {
+ type: DataTypes.STRING,
+ primaryKey: true
+ },
+ doneToken: {
+ type: DataTypes.STRING
+ },
+ status: {
+ type: DataTypes.STRING,
+ defaultValue: 'pending'
+ },
+ result: {
+ type: DataTypes.TEXT
+ },
+ createdAt: {
+ type: DataTypes.DATE,
+ allowNull: false
+ },
+ updatedAt: {
+ type: DataTypes.DATE,
+ allowNull: false
+ }
+ })
+ await context.addIndex('AsyncLoginSessions', { name: 'async_login_sessions_done_token', fields: ['doneToken'] })
+ },
+ down: async (context, Sequelize) => { }
+}
diff --git a/forge/db/models/AsyncLoginSession.js b/forge/db/models/AsyncLoginSession.js
new file mode 100644
index 0000000000..8447ea6e2a
--- /dev/null
+++ b/forge/db/models/AsyncLoginSession.js
@@ -0,0 +1,71 @@
+const { DataTypes } = require('sequelize')
+
+const { generateToken } = require('../utils')
+
+async function validateSessionExpiry (session) {
+ if (session) {
+ const age = Date.now() - session.createdAt.getTime()
+ if (age > 1000 * 60 * 30) {
+ // session is older than 30 minutes, expire it
+ await session.destroy()
+ return null
+ }
+ }
+ return session
+}
+
+module.exports = {
+ name: 'AsyncLoginSession',
+ schema: {
+ sessionToken: {
+ type: DataTypes.STRING,
+ primaryKey: true
+ },
+ doneToken: {
+ type: DataTypes.STRING
+ },
+ status: {
+ type: DataTypes.STRING,
+ defaultValue: 'pending'
+ },
+ result: {
+ type: DataTypes.TEXT
+ }
+ },
+ indexes: [
+ { name: 'async_login_sessions_done_token', fields: ['doneToken'] }
+ ],
+ finders: function (M) {
+ return {
+ static: {
+ createToken: async () => {
+ const sessionToken = generateToken(16)
+ const doneToken = generateToken(32)
+ const session = await this.create({
+ sessionToken,
+ doneToken
+ })
+ return session
+ },
+ bySessionToken: async (sessionToken) => {
+ const session = await this.findOne({
+ where: { sessionToken }
+ })
+ return validateSessionExpiry(session)
+ },
+ getAndExpireByDoneToken: async (doneToken) => {
+ let session = await this.findOne({
+ where: { doneToken }
+ })
+ session = await validateSessionExpiry(session)
+ if (session && session.status !== 'pending') {
+ // The session is resolved. We only allow retrieving it once,
+ // so we destroy it after retrieval.
+ await session.destroy()
+ }
+ return session
+ }
+ }
+ }
+ }
+}
diff --git a/forge/db/models/index.js b/forge/db/models/index.js
index acb7f596ce..93ae1e13ea 100644
--- a/forge/db/models/index.js
+++ b/forge/db/models/index.js
@@ -84,7 +84,8 @@ const modelTypes = [
'TeamBrokerClient',
'BrokerCredentials',
'MQTTTopicSchema',
- 'TeamBrokerAgent'
+ 'TeamBrokerAgent',
+ 'AsyncLoginSession'
]
// A local map of the known models.
diff --git a/forge/housekeeper/tasks/expireTokens.js b/forge/housekeeper/tasks/expireTokens.js
index 82a1ef14bd..5343a0ade2 100644
--- a/forge/housekeeper/tasks/expireTokens.js
+++ b/forge/housekeeper/tasks/expireTokens.js
@@ -13,5 +13,7 @@ module.exports = {
await app.db.models.AccessToken.destroy({ where: { expiresAt: { [Op.lt]: Date.now() } } })
// Remove any OAuthSession objects that were created more than 5 minutes ago
await app.db.models.OAuthSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 5 } } })
+ // Remove any AsyncLoginSession objects that were created more than 30 minutes ago
+ await app.db.models.AsyncLoginSession.destroy({ where: { createdAt: { [Op.lt]: Date.now() - 1000 * 60 * 30 } } })
}
}
diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js
index a5bfbed67a..b2fb95ade5 100644
--- a/forge/routes/api/device.js
+++ b/forge/routes/api/device.js
@@ -200,6 +200,17 @@ module.exports = async function (app) {
}
} else if (request.body?.team && request.session.User) {
// User action: check if the user is in the team and has the required role
+ if (request.body.application) {
+ // This request is to create the device and assign directly to an application
+ // Validate application is in the team
+ const application = await app.db.models.Application.byId(request.body.application)
+ if (!application || application.Team.hashid !== request.body.team) {
+ reply.code(400).send({ code: 'invalid_application', error: 'Invalid application' })
+ return
+ }
+ // Attach to the request so Granular RBAC checks can be applied
+ request.application = application
+ }
request.teamMembership = await request.session.User.getTeamMembership(request.body.team)
const hasPermission = app.needsPermission('device:create')
await hasPermission(request, reply) // hasPermission sends the error response if required which stops the request
@@ -225,7 +236,8 @@ module.exports = async function (app) {
allOf: [
{ required: ['setup'] }, // when provided, setup must be `true` (see enum below)
{ not: { required: ['name'] } }, // neither of name or team are allowed when setting up a device
- { not: { required: ['team'] } }
+ { not: { required: ['team'] } },
+ { not: { required: ['application'] } }
]
}
],
@@ -233,8 +245,10 @@ module.exports = async function (app) {
name: { type: 'string' },
type: { type: 'string' },
team: { type: 'string' },
+ application: { type: 'string' },
setup: { type: 'boolean', enum: [true] }, // enum only permits a value of true for setup
- agentHost: { type: 'string' } // future, for audit log
+ agentHost: { type: 'string' }, // future, for audit log
+ registrationSession: { type: 'string' } // optional, for async device registration flow
}
},
response: {
@@ -316,6 +330,16 @@ module.exports = async function (app) {
}
const teamMembership = await request.session.User.getTeamMembership(request.body.team, true)
team = teamMembership.get('Team')
+ // If application was provided, the preHandler will have already populated request.application
+ application = request.application || null
+
+ if (request.body.registrationSession) {
+ request.body.asyncSession = await app.db.models.AsyncLoginSession.bySessionToken(request.body.registrationSession)
+ if (!request.body.asyncSession || request.body.asyncSession.status !== 'pending') {
+ reply.code(400).send({ code: 'invalid_request', error: 'Invalid registration session' })
+ return
+ }
+ }
}
const transaction = await app.db.sequelize.transaction()
@@ -353,7 +377,7 @@ module.exports = async function (app) {
await app.auditLog.Team.team.device.created(actionedBy, null, team, device)
// When device provisioning: if a project was specified, add the device to the project
- if (provisioningMode && application) {
+ if (application) {
await assignDeviceToApplication(device, application)
await device.save()
await device.reload({
@@ -381,6 +405,14 @@ module.exports = async function (app) {
response.meta = {
ffVersion: app.config.version
}
+
+ if (request.body.asyncSession) {
+ // This is an async registration session that started with a POST to /_/register.
+ // We need to update the AsyncLoginSession with the result of this device creation
+ request.body.asyncSession.status = 'success'
+ request.body.asyncSession.result = JSON.stringify({ id: device.hashid, otc: credentials.otc })
+ await request.body.asyncSession.save()
+ }
reply.send(response)
} finally {
if (app.license.active() && app.billing) {
@@ -389,10 +421,107 @@ module.exports = async function (app) {
}
} catch (err) {
await transaction.rollback()
+ if (request.body.asyncSession) {
+ try {
+ // This is an async registration session that started with a POST to /_/register.
+ // We need to update the AsyncLoginSession with the result of this device creation
+ request.body.asyncSession.status = 'error'
+ request.body.asyncSession.result = JSON.stringify({ error: 'Failed to register device' })
+ await request.body.asyncSession.save()
+ } catch (err) { }
+ }
reply.code(400).send({ code: 'unexpected_error', error: err.toString() })
}
})
+ app.post('/_/register', {
+ config: {
+ allowAnonymous: true,
+ // Rate limit to 5 requests per 30 seconds per IP address. Given
+ // this is a manual registration flow, we don't support high volume requests
+ // from single sources.
+ rateLimit: app.config.rate_limits ? { max: 5, timeWindow: 30000 } : false
+ },
+ schema: {
+ summary: 'Start an asynchronous registration for a device',
+ response: {
+ 200: {
+ type: 'object',
+ properties: {
+ registerUrl: { type: 'string' },
+ doneUrl: { type: 'string' }
+ }
+ },
+ '4xx': {
+ $ref: 'APIError'
+ }
+ }
+ }
+ }, async (request, reply) => {
+ // Create a new AsyncLoginSession with a unique sessionToken and doneToken
+ const session = await app.db.models.AsyncLoginSession.createToken()
+ const registerUrl = `/register/remote-instance/${session.sessionToken}`
+ const doneUrl = `/api/v1/devices/_/register/done/${session.doneToken}`
+ reply.send({ registerUrl, doneUrl })
+ })
+ app.get('/_/register/status/:sessionToken', {
+ schema: {
+ summary: 'Get the status of an asynchronous registration session',
+ response: {
+ 202: {
+ type: 'object',
+ },
+ '4xx': {
+ $ref: 'APIError'
+ }
+ }
+ }
+ }, async (request, reply) => {
+ // Check if the session exists and is still pending
+ const session = await app.db.models.AsyncLoginSession.bySessionToken(request.params.sessionToken)
+ if (!session || session.status !== 'pending') {
+ reply.code(404).send({ code: 'not_found', error: 'Not found' })
+ return
+ }
+ reply.code(202).send({})
+ })
+ app.get('/_/register/done/:doneToken', {
+ config: { allowAnonymous: true },
+ schema: {
+ summary: 'Get the completion result of an asynchronous registration for a device',
+ response: {
+ 200: {
+ type: 'object',
+ properties: {
+ otc: { type: 'string' }
+ }
+ },
+ 202: {
+ type: 'object'
+ },
+ '4xx': {
+ $ref: 'APIError'
+ }
+ }
+ }
+ }, async (request, reply) => {
+ const session = await app.db.models.AsyncLoginSession.getAndExpireByDoneToken(request.params.doneToken)
+ if (!session) {
+ reply.code(404).send({ code: 'not_found', error: 'Not found' })
+ return
+ }
+ if (session.status === 'pending') {
+ reply.header('retry-after', '5').code(202).send({})
+ } else if (session.status === 'success') {
+ const result = JSON.parse(session.result)
+ reply.code(200).send(result)
+ } else if (session.status === 'error') {
+ reply.code(400).send({ code: 'unexpected_error', error: session.result })
+ } else {
+ reply.code(400).send({ code: 'unexpected_error', error: 'Unexpected error' })
+ }
+ })
+
/**
* Delete a device
* @name /api/v1/devices
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index fa8c3c2b7a..0f793be915 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -37,6 +37,11 @@
+ Return to the Device Agent to complete the setup. +
++ Keep this window open to view your Remote Instance once it has connected. +
+
+
+ If prompted, enter the following One-Time Code (OTC) in the Device Agent to complete the registration +
+ +
+
+ Remote Instance Connected +
++ Starting Node-RED... +
+
+
Select the team you want to register your new instance in:
+ ++ The registration session you are trying to use is invalid or has expired. Please restart the registration process. +
+
+