Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e747383
Add plain layout
knolleary Jul 14, 2026
ff2058c
Add AsyncLoginSession model
knolleary Jul 14, 2026
c1d238a
Add device async registration flow
knolleary Jul 14, 2026
b8c7cc9
Add async device registration flow
knolleary Jul 14, 2026
5f51877
Add index for doneToken
knolleary Jul 17, 2026
34bf5f7
Tidy up misc items from review
knolleary Jul 17, 2026
6ccb981
Change async result to Text rather than String
knolleary Jul 23, 2026
ad6d3c4
Tidy up some unused code from the views
knolleary Jul 23, 2026
5464da3
Support for a small scale spinner
knolleary Jul 23, 2026
87d8c79
Fix router link on feature unavailable banner
knolleary Jul 23, 2026
262a632
Add limits check on device step
knolleary Jul 23, 2026
da94332
Add billing info if applicable
knolleary Jul 23, 2026
05b63a8
Merge branch 'main' into 7731-asyncsession-api
knolleary Jul 23, 2026
ed3eea1
Tidy up of registration page
knolleary Jul 27, 2026
fa1619b
Update types
knolleary Jul 27, 2026
9e41703
Merge branch 'main' into 7731-asyncsession-api
knolleary Jul 27, 2026
fbb08b5
Ensure errors during device creation are reflected in AsyncSesssion
knolleary Jul 27, 2026
5090e0d
Remove expired tokens
knolleary Jul 27, 2026
ca75095
Update migration file to latest
knolleary Jul 27, 2026
f502e83
Update lockfile
knolleary Jul 27, 2026
5b7f6cb
Fix linting
knolleary Jul 27, 2026
a8d9027
Fix linting
knolleary Jul 27, 2026
ba78c44
Add rate limiting to public registration end point
knolleary Jul 29, 2026
5a94edb
Add home link to plain layout
knolleary Jul 29, 2026
cbf0687
Merge branch 'main' into 7731-asyncsession-api
knolleary Jul 29, 2026
87eba46
Update package file
knolleary Jul 29, 2026
d9f4109
Revert lock file changes
knolleary Jul 29, 2026
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
36 changes: 36 additions & 0 deletions forge/db/migrations/20260727-01-add-asyncloginsession.js
Original file line number Diff line number Diff line change
@@ -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: {
Comment thread
knolleary marked this conversation as resolved.
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) => { }
Comment thread
knolleary marked this conversation as resolved.
}
71 changes: 71 additions & 0 deletions forge/db/models/AsyncLoginSession.js
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
knolleary marked this conversation as resolved.
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
}
}
}
}
}
3 changes: 2 additions & 1 deletion forge/db/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ const modelTypes = [
'TeamBrokerClient',
'BrokerCredentials',
'MQTTTopicSchema',
'TeamBrokerAgent'
'TeamBrokerAgent',
'AsyncLoginSession'
]

// A local map of the known models.
Expand Down
2 changes: 2 additions & 0 deletions forge/housekeeper/tasks/expireTokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } })
}
}
135 changes: 132 additions & 3 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -225,16 +236,19 @@ 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'] } }
]
}
],
properties: {
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: {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand All @@ -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', {
Comment thread
cstns marked this conversation as resolved.
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
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
<router-view />
</ff-layout-immersive>
</template>
<template v-else-if="pageLayout === 'plain'">
<ff-layout-plain>
<router-view />
</ff-layout-plain>
</template>
<EducationModal />
</template>
<!-- Password Reset Required -->
Expand Down Expand Up @@ -73,7 +78,9 @@ import EducationModal from './components/dialogs/EducationModal.vue'
import FFLayoutBox from './layouts/Box.vue'
import FFLayoutDocs from './layouts/Docs.vue'
import FFLayoutImmersiveEditor from './layouts/ImmersiveEditor.vue'
import FFLayoutPlain from './layouts/Plain.vue'
import FFLayoutPlatform from './layouts/Platform.vue'

import Login from './pages/Login.vue'
import PasswordExpired from './pages/PasswordExpired.vue'
import TermsAndConditions from './pages/TermsAndConditions.vue'
Expand Down Expand Up @@ -103,7 +110,8 @@ export default {
'ff-layout-platform': FFLayoutPlatform,
'ff-layout-box': FFLayoutBox,
'ff-layout-docs': FFLayoutDocs,
'ff-layout-immersive': FFLayoutImmersiveEditor
'ff-layout-immersive': FFLayoutImmersiveEditor,
'ff-layout-plain': FFLayoutPlain
},
computed: {
...mapState(useUxDrawersStore, ['hiddenLeftDrawer']),
Expand Down Expand Up @@ -143,7 +151,7 @@ export default {
},
pageLayout () {
const layout = this.$route.meta?.layout
return ['platform', 'modal', 'docs', 'immersive'].includes(layout) ? layout : 'platform'
return ['platform', 'modal', 'docs', 'immersive', 'plain'].includes(layout) ? layout : 'platform'
}
},
watch: {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/api/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ const updateHTTPToken = async (deviceId, tokenId, scope, expiresAt) => {
const deleteHTTPToken = async (deviceId, tokenId) => {
return client.delete(`/api/v1/devices/${deviceId}/httpTokens/${tokenId}`)
}

const checkRegistrationSession = async (sessionToken) => {
return client.get(`/api/v1/devices/_/register/status/${sessionToken}`).then(res => res.data)
}
export default {
create,
getDevice,
Expand Down Expand Up @@ -300,5 +302,6 @@ export default {
getHTTPTokens,
createHTTPToken,
updateHTTPToken,
deleteHTTPToken
deleteHTTPToken,
checkRegistrationSession
}
Loading
Loading