Header-based Authorization

This flow provides "Header-based Authorization" for HTTP endpoints which are intended for certain users only.

This flow is part of a set of node-red-authorization-examples but also published here for easier lookup

Prerequisites

This example requires the following Node-RED extension:

Additionally, it expects the global flow context to contain an object called UserRegistry which has the same format as described in "node-red-within-express":

  • the object's property names are the ids of registered users
    user ids are strings with no specific format, they may be user names, email addresses or any other data you are free to choose - with two important exceptions: user ids must neither contain any slashes ("/") nor any colons (":") or the authentication mechanisms described below (and the user management described in node-red-user-management-example) will fail. Additionally, upper and lower case in user ids is not distinguished
  • the object's property values are JavaScript objects with the following properties, at least (additional properties may be added at will):
    • Roles
      is either missing or contains a list of strings with the user's roles. There is no specific format for role names except that a role name must not contain any white space
    • Salt
      is a string containing a random "salt" value which is used during PBKDF2 password hash calculation
    • Hash
      is a string containing the actual PBKDF2 hash of the user's password

When used outside "node-red-within-express", the following flows allow such a registry to be loaded from an external JSON file called registeredUsers.json (or to be created if no such file exists or an existing file can not be loaded) and written back after changes:

outside-node-red-within-express

These flows are already part of this example but may be removed (or customized) if not needed.

For testing and debugging purposes, the following flow may also be imported, which dumps the current contents of the user registry onto Node-RED's debug console when clicked:

show-user-registry

This flow is also already part of this example.

Postman Collection

For testing purposes, the GitHub repository for this example additionally contains a Postman collection with a few predefined requests.

Header-based Authorization

This example generates access tokens and stores them in an HTTP header instead of a cookie. This avoids having to follow any cookie-related laws but requires some JavaScript on the client side which always adds a proper authorization header to any outgoing request.

Access tokens consist of a user id and an expiration time. While they are stored in plain text (and, thus, may be inspected by the client), their value is secured with a "message digest" - as a consequence, any attempt to change a token will inevitably be recognized and lead to authorization loss. On the other hand, any successful token validation automatically refreshes that token - tokens therefore effectively expire after a certain time of inactivity only.

The key used to generate message digests is randomly chosen at server startup - a server restart will therefore automatically invalidate any active sessions.

Token lifetime may be configured, by default, it is set to 2 minutes.

In order to "login", POST a form containing the variables UserId and Password to the proper endpoint (/header-auth in this example).

header-auth

The upper outputs are used for successful authentications and logins, the lower ones for failures.

If you require the authenticating user to have a specific role, you may set msg.requiredRole to that role before invoking header auth or header login - otherwise, user roles will not be checked.

Upon successful authentication, msg.authenticatedUser contains the id of the authenticated user and msg.authorizedRoles contains a (possibly empty) list with the roles of that user.

Try yourself

The following example illustrates how to integrate Header-based authentication into Node-RED flows:

  • send a POST request to the shown entry point in order to log-in and then
  • send a GET request to the same entry point to validate that log-in

(The included Postman collection facilitates these steps as it also automatically copies the authorization header of any successful authentication into the GET request.)

Sending GET requests without prior login (or after token expiration) should fail with status code 401 (Unauthorized)

The login request should either contain

  • a body of type "application/json" with the JSON serialization of an object containing the properties UserId and Password, at least, or
  • a body of type "application/x-www-form-urlencoded" with the form variables UserId and Password, at least.

Additional object properties or form variables will be ignored by the authentication itself, but passed on to any following nodes.

try-header-auth

Successful login, token validation and token refresh always add the required header to the headers property of the msg object which, thus, automatically becomes part of the response to the incoming request.

Any login or token validation failure automatically deletes the authorization header, comparable to a logout.

Automated Tests

The GitHub repository for this example also contains some flows for automated tests.

[{"id":"5f40353168982c37","type":"comment","z":"1ab76f62fe829c73","name":"Header-based authorization (w/ expiration)","info":"","x":180,"y":40,"wires":[]},{"id":"8d77be6450ce0f2a","type":"function","z":"1ab76f62fe829c73","name":"validate authorization","func":"  let TokenHeader = msg.req.headers['authorization'] || ''\n  if (TokenHeader.startsWith('Bearer')) {\n    let UserRegistry = global.get('UserRegistry') || Object.create(null)\n\n    let Token = TokenHeader.replace(/^Bearer\\s+/,'').trim()\n    if (Token !== '') {\n      let [UserId,Expiration,Digest] = Token.split(':')\n        UserId = UserId.toLowerCase()\n      if (\n        (UserId !== '') && (UserId in UserRegistry) && (UserRegistry[UserId] != null) &&\n        /^\\d+$/.test(Expiration) && /^[0-9a-fA-F]+$/.test(Digest)\n      ) {\n        let TokenKey = global.get('TokenKey')\n          const HMAC = crypto.createHmac('sha256',TokenKey)\n          HMAC.update(UserId + ':' + Expiration)\n        let expectedDigest = HMAC.digest('hex')\n\n        if (\n          (Digest === expectedDigest) &&\n          (parseInt(Expiration,10) >= Date.now())\n        ) {\n          let UserRoles = UserRegistry[UserId].Roles || []\n          if (\n            (msg.requiredRole == null) ||\n            (UserRoles.indexOf(msg.requiredRole) >= 0)\n          ) {\n            msg.authenticatedUser = UserId\n            msg.authorizedRoles   = UserRoles\n\n            msg.headers = msg.headers || {}\n            msg.headers['authorization'] = 'Bearer ' + Token\n            return [msg,null]                                      // authorized\n          } else {\n            msg.cookies = msg.cookies || {}\n            msg.cookies.authorization = null\n\n            msg.payload    = 'Unauthorized'\n            msg.statusCode = 401\n            return [null,msg]                                  // not authorized\n          }\n        }\n      }\n    }\n  }\n\n  msg.payload    = 'Unauthorized'\n  msg.statusCode = 401\n  return [null,msg]                                            // not authorized\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":160,"y":300,"wires":[["79785206c30e98c3"],["478efb8ea02f84dd"]]},{"id":"b21258d4f3b19a31","type":"function","z":"1ab76f62fe829c73","name":"validate credentials","func":"  let UserId   = (msg.payload.UserId  || '').toLowerCase()\n  let Password = msg.payload.Password || ''\n\n  let UserRegistry = global.get('UserRegistry') || Object.create(null)\n  if ((UserId in UserRegistry) && (UserRegistry[UserId] != null)) {\n    let UserSpecs = UserRegistry[UserId]\n    if (UserSpecs.Password === Password) {              // internal optimization\n      return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n    }\n\n    let PBKDF2Iterations = global.get('PBKDF2Iterations') || 100000\n    crypto.pbkdf2(\n      Password, Buffer.from(UserSpecs.Salt,'hex'), PBKDF2Iterations, 64, 'sha512',\n      function (Error, computedHash) {\n        if ((Error == null) && (computedHash.toString('hex') === UserSpecs.Hash)) {\n          UserSpecs.Password = Password       // speeds up future auth. requests\n          return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n        } else {\n          return withoutAuthorization()\n        }\n      }\n    )\n  } else {\n    return withoutAuthorization()\n  }\n\n  function withAuthorizationOf (UserName, UserRoles) {\n    if ((msg.requiredRole == null) || (UserRoles.indexOf(msg.requiredRole) >= 0)) {\n      msg.authenticatedUser = UserId\n      msg.authorizedRoles   = UserRoles\n\n      node.send([msg,null])\n      node.done()\n    } else {\n      return withoutAuthorization()\n    }\n  }\n\n  function withoutAuthorization () {\n    msg.payload    = 'Unauthorized'\n    msg.statusCode = 401\n\n    node.send([null,msg])\n    node.done()\n  }\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":150,"y":420,"wires":[["6e31b911deebaca2"],["d30b6243d1a61ddc"]]},{"id":"3ae11bc056bb4ea5","type":"inject","z":"1ab76f62fe829c73","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":160,"wires":[["02f5a2c430d381b9"]]},{"id":"02f5a2c430d381b9","type":"function","z":"1ab76f62fe829c73","name":"generate Token Key","func":"  let TokenKey = global.get('TokenKey')\n  if (TokenKey == null) {     // do not change TokenKey upon Node-RED deployment\n    global.set('TokenKey',crypto.randomBytes(16).toString('hex'))\n  }\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":320,"y":160,"wires":[["08c6f38667b1f597"]]},{"id":"08c6f38667b1f597","type":"debug","z":"1ab76f62fe829c73","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'token key generated'","statusType":"jsonata","x":530,"y":160,"wires":[]},{"id":"3165fc5071ea3432","type":"inject","z":"1ab76f62fe829c73","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":100,"wires":[["4c8bc88e9138d602"]]},{"id":"4c8bc88e9138d602","type":"change","z":"1ab76f62fe829c73","name":"set token lifetime","rules":[{"t":"set","p":"TokenLifetime","pt":"global","to":"2*60*1000","tot":"jsonata"},{"t":"set","p":"payload","pt":"msg","to":"TokenLifetime","tot":"global"}],"action":"","property":"","from":"","to":"","reg":false,"x":310,"y":100,"wires":[["59d1b07f62cd0149"]]},{"id":"59d1b07f62cd0149","type":"debug","z":"1ab76f62fe829c73","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"msg","x":530,"y":100,"wires":[]},{"id":"79785206c30e98c3","type":"function","z":"1ab76f62fe829c73","name":"refresh token","func":"  let Expiration   = Date.now() + global.get('TokenLifetime')\n  let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n  let TokenKey = global.get('TokenKey')\n    const HMAC = crypto.createHmac('sha256',TokenKey)\n    HMAC.update(TokenContent)\n  let Digest = HMAC.digest('hex')\n\n  msg.headers = msg.headers || {}\n  msg.headers['authorization'] = 'Bearer ' + TokenContent + ':' + Digest\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":330,"y":240,"wires":[["2006e13c1c6ae4a2"]]},{"id":"6e31b911deebaca2","type":"function","z":"1ab76f62fe829c73","name":"create token","func":"  let Expiration   = Date.now() + global.get('TokenLifetime')\n  let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n  let TokenKey = global.get('TokenKey')\n    const HMAC = crypto.createHmac('sha256',TokenKey)\n    HMAC.update(TokenContent)\n  let Digest = HMAC.digest('hex')\n\n  msg.headers = msg.headers || {}\n  msg.headers['authorization'] = 'Bearer ' + TokenContent + ':' + Digest\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":350,"y":360,"wires":[["71fe29a8a838e801"]]},{"id":"5a6436e0f351d31c","type":"reusable-in","z":"1ab76f62fe829c73","name":"header auth","info":"describe your reusable flow here","scope":"global","x":90,"y":240,"wires":[["8d77be6450ce0f2a"]]},{"id":"53a1975a92fb3b0d","type":"reusable-in","z":"1ab76f62fe829c73","name":"header login","info":"describe your reusable flow here","scope":"global","x":90,"y":360,"wires":[["b21258d4f3b19a31"]]},{"id":"2006e13c1c6ae4a2","type":"reusable-out","z":"1ab76f62fe829c73","name":"authorized","position":1,"x":510,"y":240,"wires":[]},{"id":"478efb8ea02f84dd","type":"reusable-out","z":"1ab76f62fe829c73","name":"unauthorized","position":"2","x":510,"y":280,"wires":[]},{"id":"71fe29a8a838e801","type":"reusable-out","z":"1ab76f62fe829c73","name":"success","position":1,"x":520,"y":360,"wires":[]},{"id":"d30b6243d1a61ddc","type":"reusable-out","z":"1ab76f62fe829c73","name":"failure","position":"2","x":530,"y":400,"wires":[]},{"id":"26648eb5bf6204e9","type":"comment","z":"1ab76f62fe829c73","name":"try yourself","info":"","x":80,"y":500,"wires":[]},{"id":"b658a6de0857907b","type":"http in","z":"1ab76f62fe829c73","name":"","url":"header-auth","method":"get","upload":false,"swaggerDoc":"","x":110,"y":560,"wires":[["dc105a4977025043"]]},{"id":"ecd50a9259a2cfde","type":"http response","z":"1ab76f62fe829c73","name":"","statusCode":"","headers":{},"x":530,"y":620,"wires":[]},{"id":"af1aef5eff24af57","type":"change","z":"1ab76f62fe829c73","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":620,"wires":[["ecd50a9259a2cfde"]]},{"id":"d44680247d011e9b","type":"http in","z":"1ab76f62fe829c73","name":"","url":"header-auth","method":"post","upload":false,"swaggerDoc":"","x":110,"y":680,"wires":[["2f655d899fbe9841"]]},{"id":"8c7ae8b543f3576a","type":"change","z":"1ab76f62fe829c73","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":740,"wires":[["d405c4344cb50dcb"]]},{"id":"d405c4344cb50dcb","type":"http response","z":"1ab76f62fe829c73","name":"","statusCode":"","headers":{},"x":530,"y":740,"wires":[]},{"id":"dc105a4977025043","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"header auth","outputs":2,"x":310,"y":560,"wires":[["af1aef5eff24af57"],["ecd50a9259a2cfde"]]},{"id":"2f655d899fbe9841","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"header login","outputs":2,"x":310,"y":680,"wires":[["8c7ae8b543f3576a"],["d405c4344cb50dcb"]]},{"id":"a437ea9e9983f2b9","type":"comment","z":"1ab76f62fe829c73","name":"if used without \"node-red-within-express\"","info":"","x":180,"y":820,"wires":[]},{"id":"ed50b5b2e94746c8","type":"inject","z":"1ab76f62fe829c73","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":100,"y":880,"wires":[["4a4c700ae4ecd934"]]},{"id":"2d98c64a67e881e7","type":"file in","z":"1ab76f62fe829c73","name":"","filename":"./registeredUsers.json","format":"utf8","chunk":false,"sendError":false,"encoding":"utf8","allProps":false,"x":160,"y":1000,"wires":[["70c82a6e25251a70"]]},{"id":"470b387186d7852a","type":"catch","z":"1ab76f62fe829c73","name":"","scope":["2d98c64a67e881e7","70c82a6e25251a70"],"uncaught":false,"x":110,"y":1060,"wires":[["46809118b7d27252"]]},{"id":"a52bccab8bc183a8","type":"debug","z":"1ab76f62fe829c73","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not load user registry'","statusType":"jsonata","x":510,"y":1060,"wires":[]},{"id":"3eb566d1907d9b3b","type":"debug","z":"1ab76f62fe829c73","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'user registry available'","statusType":"jsonata","x":530,"y":880,"wires":[]},{"id":"46809118b7d27252","type":"function","z":"1ab76f62fe829c73","name":"create in global context","func":"  let UserRegistry = Object.create(null)\n    UserRegistry['node-red'] =  {\n      Roles: ['node-red'],\n      Salt: '4486e8d35b8275020b1301226cc77963',\n      Hash: 'ab2b740ea9148aa4f320af3f3ba60ee2e33bb8039c57eea2b29579ff3f3b16bec2401f19e3c6ed8ad36de432b80b6f973a12c41af5d50738e4bb902d0117df53'\n    }\n  global.set('UserRegistry',UserRegistry)\n\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":1060,"wires":[["a52bccab8bc183a8","e0b359a7b5f90189"]]},{"id":"a463788d4c5c13ab","type":"file","z":"1ab76f62fe829c73","name":"","filename":"./registeredUsers.json","appendNewline":false,"createDir":false,"overwriteFile":"true","encoding":"utf8","x":420,"y":1180,"wires":[["53948a7e0233c789"]]},{"id":"0892a7393f0e2863","type":"catch","z":"1ab76f62fe829c73","name":"","scope":["a463788d4c5c13ab"],"uncaught":false,"x":110,"y":1300,"wires":[["e50a7cafe871861b","d97e0ea28a9f3c81"]]},{"id":"e50a7cafe871861b","type":"debug","z":"1ab76f62fe829c73","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not write user registry'","statusType":"jsonata","x":250,"y":1340,"wires":[]},{"id":"53948a7e0233c789","type":"change","z":"1ab76f62fe829c73","name":"restore payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"_payload","tot":"msg"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":180,"y":1240,"wires":[["1ce07b54225e17dd"]]},{"id":"d97e0ea28a9f3c81","type":"change","z":"1ab76f62fe829c73","name":"report in payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"'Internal Server Error'","tot":"jsonata"},{"t":"set","p":"statusCode","pt":"msg","to":"500","tot":"str"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":290,"y":1300,"wires":[["1ce07b54225e17dd"]]},{"id":"70c82a6e25251a70","type":"function","z":"1ab76f62fe829c73","name":"write to global context","func":"  let UserSet = JSON.parse(msg.payload)                             // may fail!\n\n  let UserRegistry = Object.create(null)\n  for (let UserId in UserSet) {\n    if (UserSet.hasOwnProperty(UserId)) {\n      if ((UserId.indexOf('/') >= 0) || (UserId.indexOf(':') >= 0)) {\n        throw 'Invalid character in UserId found'\n      }\n      \n      UserRegistry[UserId.toLowerCase()] = UserSet[UserId]\n    }\n  }\n  global.set('UserRegistry',UserRegistry)\n\n  msg.payload = ''\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":400,"y":1000,"wires":[["e0b359a7b5f90189"]]},{"id":"4f110197c9f2a334","type":"function","z":"1ab76f62fe829c73","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":940,"wires":[["46809118b7d27252"]]},{"id":"687ebf9f0226b7dc","type":"function","z":"1ab76f62fe829c73","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":1120,"wires":[["d97e0ea28a9f3c81"]]},{"id":"848c6ddfb76643e4","type":"function","z":"1ab76f62fe829c73","name":"read from global context","func":"  let UserRegistry = global.get('UserRegistry')\n  let UserSet = {}\n  for (let UserId in UserRegistry) {\n    if (UserRegistry[UserId] == null) {\n      UserSet[UserId] = null\n    } else {\n      let UserEntry = Object.assign({},UserRegistry[UserId])\n        delete UserEntry.Password     // never write passwords in plain text!\n      UserSet[UserId] = UserEntry\n    }\n  }\n\n  msg.payload = JSON.stringify(UserSet)\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":170,"y":1180,"wires":[["a463788d4c5c13ab"]]},{"id":"e0b359a7b5f90189","type":"reusable-out","z":"1ab76f62fe829c73","name":"return","position":1,"x":530,"y":940,"wires":[]},{"id":"1ce07b54225e17dd","type":"reusable-out","z":"1ab76f62fe829c73","name":"return","position":1,"x":530,"y":1240,"wires":[]},{"id":"254d667c7cc38a4e","type":"reusable-in","z":"1ab76f62fe829c73","name":"load or create UserRegistry","info":"describe your reusable flow here","scope":"global","x":140,"y":940,"wires":[["4f110197c9f2a334","2d98c64a67e881e7"]]},{"id":"e50f6e5eb55f7d87","type":"reusable-in","z":"1ab76f62fe829c73","name":"write UserRegistry","info":"describe your reusable flow here","scope":"global","x":110,"y":1120,"wires":[["687ebf9f0226b7dc","848c6ddfb76643e4"]]},{"id":"4a4c700ae4ecd934","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"load or create userregistry","outputs":1,"x":320,"y":880,"wires":[["3eb566d1907d9b3b"]]},{"id":"fe9d58b1f84fce63","type":"inject","z":"1ab76f62fe829c73","name":"show UserRegistry","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":130,"y":1480,"wires":[["663a2575d83ff39f"]]},{"id":"80fab4a14a0c61a7","type":"comment","z":"1ab76f62fe829c73","name":"for testing purposes","info":"","x":110,"y":1420,"wires":[]},{"id":"663a2575d83ff39f","type":"function","z":"1ab76f62fe829c73","name":"create output","func":"let UserRegistry = global.get('UserRegistry') || Object.create(null)\n  let UserList = []\n  for (let UserId in UserRegistry) {\n    UserList.push(\n      UserRegistry[UserId] == null ? '[' + UserId + ']' : UserId\n    )\n  }\nmsg.payload = (\n  UserList.length === 0\n  ? '(no user registered)'\n  : 'registered users: \"' + UserList.join('\",\"') + '\"'\n)\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":1480,"wires":[["62b2658722dbd9e7"]]},{"id":"62b2658722dbd9e7","type":"debug","z":"1ab76f62fe829c73","name":"show","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":510,"y":1480,"wires":[]}]

Flow Info

Created 2 years, 8 months ago
Rating: 1 1

Owner

Actions

Rate:

Node Types

Core
  • catch (x2)
  • change (x5)
  • comment (x4)
  • debug (x6)
  • file (x1)
  • file in (x1)
  • function (x11)
  • http in (x2)
  • http response (x2)
  • inject (x4)
Other

Tags

  • authentication
  • authorization
  • header
  • bearer-token
  • security
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option