Valid bearer token

I have a flow that consumes the Spotify API that does this, let me sanitise it a bit and upload it for you. This token is valid for 1 hour. There's 2 options you can take: request either way and if it comes back as invalid refresh and store the token again, or keep a timestamp of the last refresh and if it's longer ago than 95% of your token expiration time refresh it just to be safe that it doesn't run out during your request sequence.

I have the OAuth callback set up in an http-in/http-response structure, so that the initial bearer token and the refresh token arrive in Node-RED immediately, and I store them in the flow context. I store the time of last refresh there too. I've a subflow that handles the checking if the request has expired yet and if so refresh before continuing with the actual request.

Turns out it's already pretty sanitised. It's all core nodes, except for one instance of node-red-contrib-credentials that I use to store client id/client secret.

[{"id":"3de50bd9.3572a4","type":"subflow","name":"Spotify: refresh token","info":"","category":"","in":[{"x":60,"y":80,"wires":[{"id":"1cd24155.73c72f"}]}],"out":[{"x":600,"y":220,"wires":[{"id":"3f20070a.f1bed","port":1},{"id":"d05015c8.d2934","port":0}]}],"env":[{"name":"spotify_obj_path","type":"str","value":"flow.spotify","ui":{"label":{"en-US":"Where is the spotify object located? (local path)"},"type":"input","opts":{"types":["str","num","bool","json","bin","env"]}}}],"color":"#DDAA99"},{"id":"1cd24155.73c72f","type":"function","z":"3de50bd9.3572a4","name":"Needs refresh?","func":"let tokens;\nconst path = env.get('spotify_obj_path');\n\nif (path.startsWith('flow.')) {\n    msg._spotify_store_type = 'flow';\n    const pos = 'flow.'.length;\n    tokens = flow.get('$parent.' + path.slice(pos)).tokens;\n}\nelse if (path.startsWith('global.')) {\n    msg._spotify_store_type = 'global';\n    const pos = 'global.'.length;\n    tokens = global.get(path.slice(pos)).tokens;\n}\nelse {\n    node.error(\"Spotify token storage type unknown. Please prefix with `flow.` or `global.`.\", msg);\n    return null\n}\n\nnow = Date.now();\nif (now - 60000 >= tokens.expires_at) {\n    // Token will expire within in a minute or is already gone. \n    // Refresh now to prevent the following flow part to fail.\n    msg.payload = true;\n    msg._tokens = tokens;\n}\nelse {\n    msg.payload = false;\n}\n\nreturn msg;","outputs":1,"noerr":0,"x":220,"y":80,"wires":[["3f20070a.f1bed"]]},{"id":"3f20070a.f1bed","type":"switch","z":"3de50bd9.3572a4","name":"","property":"payload","propertyType":"msg","rules":[{"t":"true"},{"t":"false"}],"checkall":"true","repair":false,"outputs":2,"x":390,"y":80,"wires":[["d113569f.677b7"],[]],"outputLabels":["refresh","skip"]},{"id":"d113569f.677b7","type":"change","z":"3de50bd9.3572a4","name":"Prepare request","rules":[{"t":"set","p":"payload","pt":"msg","to":"{}","tot":"json"},{"t":"set","p":"payload.grant_type","pt":"msg","to":"refresh_token","tot":"str"},{"t":"set","p":"payload.refresh_token","pt":"msg","to":"_tokens.refresh_token","tot":"msg"},{"t":"set","p":"headers","pt":"msg","to":"(\t   $spotifyObj := $$._spotify_store_type = 'flow' ? $flowContext(\t       '$parent.' & $substringAfter(\t           $env('spotify_obj_path'),\t           'flow.'\t       )\t   ) : $$._spotify_store_type = 'global' ? $globalContext(\t       $substringAfter(\t           $env('spotify_obj_path'),\t           'global.'\t       )\t   ) : null;\t   {\t       \"content-type\": \"application/x-www-form-urlencoded\",\t       \"authorization\": \"Basic \" & $base64encode(\t           $spotifyObj.client_id & \":\" & $spotifyObj.client_secret\t       )\t    \t   }\t\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":660,"y":40,"wires":[["df74278c.9825e8"]]},{"id":"df74278c.9825e8","type":"http request","z":"3de50bd9.3572a4","name":"","method":"POST","ret":"obj","paytoqs":false,"url":"https://accounts.spotify.com/api/token","tls":"","persist":false,"proxy":"","authType":"","x":650,"y":80,"wires":[["a0a69ccb.48766"]]},{"id":"a0a69ccb.48766","type":"function","z":"3de50bd9.3572a4","name":"Prep for saving","func":"msg.payload.expires_at = msg.payload.expires_in * 1000 + Date.now()\nreturn msg;","outputs":1,"noerr":0,"x":660,"y":120,"wires":[["d05015c8.d2934"]]},{"id":"d05015c8.d2934","type":"function","z":"3de50bd9.3572a4","name":"Save tokens","func":"if (msg._spotify_store_type === 'flow') {\n    let base = flow.get('$parent.' + env.get('spotify_obj_path').slice('flow.'.length));\n    base.tokens.access_token = msg.payload.access_token;\n    base.tokens.expires_at = msg.payload.expires_at;\n    flow.set('$parent.' + env.get('spotify_obj_path').slice('flow.'.length), base);\n}\nelse if (msg._spotify_store_type === 'global') {\n    let base = global.get(env.get('spotify_obj_path').slice('global.'.length));\n    base.tokens.access_token = msg.payload.access_token;\n    base.tokens.expires_at = msg.payload.expires_at;\n    flow.set(env.get('spotify_obj_path').slice('global.'.length), base);\n}\nreturn msg;","outputs":1,"noerr":0,"x":650,"y":160,"wires":[[]]},{"id":"5c7f0115.050d38","type":"tab","label":"Spotify","disabled":false,"info":""},{"id":"ac470a4b.382358","type":"http in","z":"5c7f0115.050d38","name":"","url":"/oauth/neela/spotify-callback","method":"get","upload":false,"swaggerDoc":"","x":180,"y":260,"wires":[["f3a46d61.17a578"]]},{"id":"b3dd92fa.0cef28","type":"http response","z":"5c7f0115.050d38","name":"","statusCode":"200","headers":{},"x":1040,"y":260,"wires":[]},{"id":"2e292b14.8c4f14","type":"http request","z":"5c7f0115.050d38","name":"Code to Access Token","method":"POST","ret":"obj","paytoqs":false,"url":"https://accounts.spotify.com/api/token","tls":"","proxy":"","authType":"basic","x":660,"y":306,"wires":[["a095d367.140408","882839ec.1ed54"]]},{"id":"f3a46d61.17a578","type":"change","z":"5c7f0115.050d38","name":"Prepare body","rules":[{"t":"set","p":"payload.grant_type","pt":"msg","to":"authorization_code","tot":"str"},{"t":"set","p":"payload.redirect_uri","pt":"msg","to":"spotify.constants.redirect_uri","tot":"flow"},{"t":"set","p":"payload.client_id","pt":"msg","to":"spotify.client_id","tot":"flow"},{"t":"set","p":"payload.client_secret","pt":"msg","to":"spotify.client_secret","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":444,"y":259,"wires":[["fa6662f6.f5f9f8"]]},{"id":"5cc32435.25ce7c","type":"debug","z":"5c7f0115.050d38","name":"request prepped","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":637,"y":370,"wires":[]},{"id":"fa6662f6.f5f9f8","type":"change","z":"5c7f0115.050d38","name":"Prepare headers","rules":[{"t":"set","p":"headers['content-type']","pt":"msg","to":"application/x-www-form-urlencoded","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":448,"y":306,"wires":[["5cc32435.25ce7c","2e292b14.8c4f14"]]},{"id":"879219b2.d83ec","type":"comment","z":"5c7f0115.050d38","name":"Spotify Connect OAuth 2.0","info":"","x":150,"y":40,"wires":[]},{"id":"ff46d68c.7f4c48","type":"change","z":"5c7f0115.050d38","name":"Save tokens","rules":[{"t":"set","p":"spotify.tokens","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1050,"y":320,"wires":[["dcac984d.847a08"]]},{"id":"a095d367.140408","type":"template","z":"5c7f0115.050d38","name":"","field":"payload","fieldType":"msg","format":"html","syntax":"mustache","template":"<!doctype html>\n<html>\n    <head><title>Spotify Connect</title></head>\n    <body>\n        <h1>Thank you for connecting to Spotify</h1>\n        <p>You can now close this page.</p>\n    </body>\n</html>","output":"str","x":860,"y":260,"wires":[["b3dd92fa.0cef28"]]},{"id":"dcac984d.847a08","type":"mqtt out","z":"5c7f0115.050d38","d":true,"name":"","topic":"neela/music/spotify/register","qos":"2","retain":"","broker":"9646c685.9f4cc","x":1280,"y":320,"wires":[]},{"id":"89fb4a3d.870588","type":"comment","z":"5c7f0115.050d38","name":"Spotify API","info":"","x":100,"y":480,"wires":[]},{"id":"972280e6.e8e698","type":"link in","z":"5c7f0115.050d38","name":"Spotify: Pause","links":["b03276a4.d13e58"],"x":75,"y":580,"wires":[["996432e5.5d99b8"]]},{"id":"a92e4cfb.323e28","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"Authorization\": \"Bearer \" & $flowContext('spotify').tokens.access_token\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":380,"y":580,"wires":[["543aa23f.1564dc"]]},{"id":"ae78ac7c.0b864","type":"credentials","z":"5c7f0115.050d38","name":"Spotify api credentials","props":[{"value":"spotify.client_id","type":"flow"},{"value":"spotify.client_secret","type":"flow"}],"x":600,"y":80,"wires":[[]]},{"id":"c970c7eb.9536d","type":"inject","z":"5c7f0115.050d38","name":"On flow start...","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":true,"onceDelay":0.1,"x":140,"y":80,"wires":[["9a292c6e.1fe4e"]]},{"id":"50af3ee7.e87cd","type":"change","z":"5c7f0115.050d38","name":"URL parameters","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"payload.client_id","pt":"msg","to":"spotify.client_id","tot":"flow"},{"t":"set","p":"payload.scope","pt":"msg","to":"$join(\t   [\t       'user-read-playback-state',\t       'user-modify-playback-state',\t       'user-read-currently-playing',\t       'user-read-recently-played'\t   ],\t   ' '\t)","tot":"jsonata"},{"t":"set","p":"payload.response_type","pt":"msg","to":"code","tot":"str"},{"t":"set","p":"payload.redirect_uri","pt":"msg","to":"spotify.constants.redirect_uri","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":160,"wires":[["eb2a48a1.cf119"]]},{"id":"9a292c6e.1fe4e","type":"change","z":"5c7f0115.050d38","name":"Set Spotify constants","rules":[{"t":"set","p":"spotify.constants","pt":"flow","to":"{\"authorize_url\":\"https://accounts.spotify.com/authorize\",\"redirect_uri\":\"http://neela.local:1880/oauth/neela/spotify-callback\"}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":360,"y":80,"wires":[["ae78ac7c.0b864"]]},{"id":"eb2a48a1.cf119","type":"change","z":"5c7f0115.050d38","name":"Format URL","rules":[{"t":"set","p":"payload","pt":"msg","to":"$encodeUrl(\t   $flowContext('spotify').constants.authorize_url & \"?\" & $join(\t       $each(\t           $$.payload,\t           function($v, $k) {$k & \"=\" & $v}\t       ),\t       '&'\t   )\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":470,"y":160,"wires":[["c897fb76.95738"]]},{"id":"cbf276e6.459498","type":"inject","z":"5c7f0115.050d38","name":"","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":120,"y":160,"wires":[["50af3ee7.e87cd"]]},{"id":"c897fb76.95738","type":"debug","z":"5c7f0115.050d38","name":"Authorise URL","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","x":640,"y":160,"wires":[]},{"id":"543aa23f.1564dc","type":"http request","z":"5c7f0115.050d38","name":"","method":"PUT","ret":"txt","paytoqs":false,"url":"https://api.spotify.com/v1/me/player/pause","tls":"","persist":false,"proxy":"","authType":"","x":570,"y":580,"wires":[["93ae8aa1.485138"]]},{"id":"93ae8aa1.485138","type":"debug","z":"5c7f0115.050d38","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","x":750,"y":580,"wires":[]},{"id":"34f27eed.a4e222","type":"comment","z":"5c7f0115.050d38","name":"Pause","info":"","x":110,"y":540,"wires":[]},{"id":"5a753fd7.eb028","type":"comment","z":"5c7f0115.050d38","name":"Play","info":"","x":110,"y":640,"wires":[]},{"id":"93e42394.480bb","type":"comment","z":"5c7f0115.050d38","name":"Playback state","info":"","x":140,"y":740,"wires":[]},{"id":"551f55de.e95fac","type":"link in","z":"5c7f0115.050d38","name":"Spotify: Playback state","links":["86eb1c1.8b018e"],"x":75,"y":780,"wires":[["a3c830b1.8fdba"]]},{"id":"435a5c32.d31884","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"Authorization\": \"Bearer \" & $flowContext('spotify').tokens.access_token\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":380,"y":780,"wires":[["8a0bf5a8.05c958"]]},{"id":"8a0bf5a8.05c958","type":"http request","z":"5c7f0115.050d38","name":"","method":"GET","ret":"obj","paytoqs":false,"url":"https://api.spotify.com/v1/me/player","tls":"","persist":false,"proxy":"","authType":"","x":570,"y":780,"wires":[["ab324d1b.d0e49","7afdfd11.be2094"]]},{"id":"ab324d1b.d0e49","type":"link out","z":"5c7f0115.050d38","name":"Spotify: Playback state result","links":["bfc752a1.de3b1"],"x":715,"y":780,"wires":[]},{"id":"f378eb76.3dfca8","type":"link in","z":"5c7f0115.050d38","name":"Spotify: Play","links":["b3d3972e.ab4e68"],"x":75,"y":680,"wires":[["157dc1c8.73c1ae"]]},{"id":"6d7067bf.86dee8","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"Authorization\": \"Bearer \" & $flowContext('spotify').tokens.access_token\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":380,"y":680,"wires":[["84636e14.0faa1"]]},{"id":"84636e14.0faa1","type":"http request","z":"5c7f0115.050d38","name":"","method":"PUT","ret":"txt","paytoqs":false,"url":"https://api.spotify.com/v1/me/player/play","tls":"","persist":false,"proxy":"","authType":"","x":570,"y":680,"wires":[["18618839.5b3e38"]]},{"id":"18618839.5b3e38","type":"debug","z":"5c7f0115.050d38","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":730,"y":680,"wires":[]},{"id":"7afdfd11.be2094","type":"debug","z":"5c7f0115.050d38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","x":730,"y":740,"wires":[]},{"id":"141c4d82.468632","type":"comment","z":"5c7f0115.050d38","name":"Refresh auth token","info":"","x":130,"y":360,"wires":[]},{"id":"30c68144.460e6e","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"set","p":"payload","pt":"msg","to":"{}","tot":"json"},{"t":"set","p":"payload.grant_type","pt":"msg","to":"refresh_token","tot":"str"},{"t":"set","p":"payload.refresh_token","pt":"msg","to":"spotify.tokens.refresh_token","tot":"flow"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"content-type\": \"application/x-www-form-urlencoded\",\t    \"authorization\": \"Basic \" & $base64encode($flowContext('spotify').client_id & \":\" & $flowContext('spotify').client_secret)\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":260,"y":420,"wires":[["82ea7a7d.e66508"]]},{"id":"82ea7a7d.e66508","type":"http request","z":"5c7f0115.050d38","name":"","method":"POST","ret":"obj","paytoqs":false,"url":"https://accounts.spotify.com/api/token","tls":"","persist":false,"proxy":"","authType":"","x":430,"y":420,"wires":[["7180e1ff.7c0fd8"]]},{"id":"e91101f7.4c6b98","type":"inject","z":"5c7f0115.050d38","name":"","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":100,"y":420,"wires":[["30c68144.460e6e"]]},{"id":"882839ec.1ed54","type":"function","z":"5c7f0115.050d38","name":"Prep for saving","func":"msg.payload.expires_at = msg.payload.expires_in * 1000 + Date.now()\nreturn msg;","outputs":1,"noerr":0,"x":880,"y":320,"wires":[["ff46d68c.7f4c48"]]},{"id":"f3fcd18e.5ff94","type":"change","z":"5c7f0115.050d38","name":"Save tokens","rules":[{"t":"set","p":"spotify.tokens.access_token","pt":"flow","to":"payload.access_token","tot":"msg"},{"t":"set","p":"spotify.tokens.expires_at","pt":"flow","to":"payload.expires_at","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":790,"y":420,"wires":[[]]},{"id":"7180e1ff.7c0fd8","type":"function","z":"5c7f0115.050d38","name":"Prep for saving","func":"msg.payload.expires_at = msg.payload.expires_in * 1000 + Date.now()\nreturn msg;","outputs":1,"noerr":0,"x":600,"y":420,"wires":[["f3fcd18e.5ff94"]]},{"id":"996432e5.5d99b8","type":"subflow:3de50bd9.3572a4","z":"5c7f0115.050d38","name":"refresh token","env":[],"x":190,"y":580,"wires":[["a92e4cfb.323e28"]]},{"id":"157dc1c8.73c1ae","type":"subflow:3de50bd9.3572a4","z":"5c7f0115.050d38","name":"refresh token","env":[],"x":190,"y":680,"wires":[["6d7067bf.86dee8"]]},{"id":"a3c830b1.8fdba","type":"subflow:3de50bd9.3572a4","z":"5c7f0115.050d38","name":"refresh token","env":[],"x":190,"y":780,"wires":[["435a5c32.d31884"]]},{"id":"133ec8bb.51a767","type":"comment","z":"5c7f0115.050d38","name":"Skip to next","info":"","x":130,"y":840,"wires":[]},{"id":"f9081f5f.c16c3","type":"link in","z":"5c7f0115.050d38","name":"Spotify: Skip to next","links":["be012162.33227"],"x":75,"y":880,"wires":[["51425c0f.4e6514"]]},{"id":"51425c0f.4e6514","type":"subflow:3de50bd9.3572a4","z":"5c7f0115.050d38","name":"refresh token","env":[],"x":190,"y":880,"wires":[["86e0af97.84b6c"]]},{"id":"86e0af97.84b6c","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"Authorization\": \"Bearer \" & $flowContext('spotify').tokens.access_token\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":380,"y":880,"wires":[["cedadf84.42fb6"]]},{"id":"cedadf84.42fb6","type":"http request","z":"5c7f0115.050d38","name":"","method":"POST","ret":"txt","paytoqs":false,"url":"https://api.spotify.com/v1/me/player/next","tls":"","persist":false,"proxy":"","authType":"","x":570,"y":880,"wires":[["727a5b52.6f4024"]]},{"id":"727a5b52.6f4024","type":"debug","z":"5c7f0115.050d38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","x":750,"y":880,"wires":[]},{"id":"7fbe5154.ed3a2","type":"comment","z":"5c7f0115.050d38","name":"Skip to previous","info":"","x":140,"y":940,"wires":[]},{"id":"62295e66.84126","type":"link in","z":"5c7f0115.050d38","name":"Spotify: Skip to previous","links":["7315d190.88587"],"x":75,"y":980,"wires":[["9800468a.4bc3e8"]]},{"id":"9800468a.4bc3e8","type":"subflow:3de50bd9.3572a4","z":"5c7f0115.050d38","name":"refresh token","env":[],"x":190,"y":980,"wires":[["65706661.85b498"]]},{"id":"65706661.85b498","type":"change","z":"5c7f0115.050d38","name":"Prepare request","rules":[{"t":"delete","p":"payload","pt":"msg"},{"t":"set","p":"headers","pt":"msg","to":"{\t    \"Authorization\": \"Bearer \" & $flowContext('spotify').tokens.access_token\t}","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":380,"y":980,"wires":[["25699b4.0e12364"]]},{"id":"25699b4.0e12364","type":"http request","z":"5c7f0115.050d38","name":"","method":"POST","ret":"txt","paytoqs":false,"url":"https://api.spotify.com/v1/me/player/previous","tls":"","persist":false,"proxy":"","authType":"","x":570,"y":980,"wires":[["4e0a369d.53b888"]]},{"id":"4e0a369d.53b888","type":"debug","z":"5c7f0115.050d38","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","x":750,"y":980,"wires":[]},{"id":"9646c685.9f4cc","type":"mqtt-broker","z":"","name":"local","broker":"neela.local","port":"1883","clientid":"","usetls":false,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closeQos":"0","closePayload":"","willTopic":"","willQos":"0","willPayload":""}]


This is the code that handles connecting and an injectable refresh for testing. One thing to keep in mind: this runs on a tab, so the flow context is that of within the tab. However, the subflow would have its own context but can reach to the parent context. I utilise that by having the subflow have a property that asks "how would you access this context variable from the current view?", and within the subflow it takes the parent of that to acces it. If you keep a similar structure up, you can even do this on nested subflows and keep it working. The code is prepared for the tokens stored on both flow and global context. I've tried documenting the jsonata syntax that I use in places, can't remember the exact state of that documentation though...

3 Likes