pckhib / node-red-contrib-spotify

Node-RED node for Spotify Web API
MIT License
13 stars 16 forks source link

WebapiError: Unauthorized #3

Open vaclavhorejsi opened 5 years ago

vaclavhorejsi commented 5 years ago

Hi, again,

after Authentication process with Spotify everything works good. But after an hour or maybe more (I am not able to make exact measurement) nodes are returning msg.error "WebapiError: Unauthorized". When I delete config node and reconfigure it again, everything starts work well.

After day later (maybe only night, I don´t know exactly again, because I was not at home) it starts returning this msg.erorr "WebapiError: Not Found". And if recreate config node, it starts be ok again.

Same configuration as I wrote in issue #2 .

Do you have some advice?

Thanks

Quentin-SPINDLER commented 5 years ago

Hi, I have the same issue. Redeploy the flow fix the issue for me. Seems to be a per node issue, so if i keep, for example, using a node (10 min timer) the node keep working but not the other one (so for the time, all of my node have a keep alive loop with a dummy message )

I hope that it can help to find the issue and help users with temporary fix ;)

Thanks for your work !

vaclavhorejsi commented 5 years ago

Hi, thanks for answer, it helps me to make some new tests around.

When message Unauthorized is appearing and I redeploy, it fix node for me. But, after long time when message Not Found appear, nothing helps me to get back to work state. Only delete configuration node and create it again.

Thanks

maciekczwa commented 5 years ago

I have the same problem, It looks like we need to implement refreshing the token automatically. Something like that: https://github.com/thelinmichael/spotify-web-api-node/blob/master/examples/access-token-refresh.js

Quentin-SPINDLER commented 5 years ago

I have the same problem, It looks like we need to implement refreshing the token automatically. Something like that: https://github.com/thelinmichael/spotify-web-api-node/blob/master/examples/access-token-refresh.js

Hi, This week i've made some test and i'm now able to refresh the token automatically. As i'm not familiar with Node-Red, Node.js and Javascript it's certainly not optimized but at least it works and may help you to implement the feature "nicely" ;)

authorize.js

module.exports = function (RED) {

    const url = require('url');
    const crypto = require('crypto');
    const SpotifyWebApi = require('spotify-web-api-node');

    function AuthNode(config) {
        RED.nodes.createNode(this, config);

        this.name = config.name;
        this.scope = config.scope;

    const ID = this.id;

    const creds = RED.nodes.getCredentials(ID);

    if(!creds.interval){
        const interval = setInterval(function(interval) {
            try {
                const credentials = RED.nodes.getCredentials(ID);

                const spotifyApi = new SpotifyWebApi({
                    clientId: credentials.clientId,
                    clientSecret: credentials.clientSecret,
                    redirectUri: credentials.callback
                    });

                // Set the access token and refresh token
                spotifyApi.setAccessToken(credentials.accessToken);
                spotifyApi.setRefreshToken(credentials.refreshToken);  

                // Refresh token and print the new time to expiration.
                    spotifyApi.refreshAccessToken().then(function(data) {
                        RED.log.info(RED.log._('SpotifyAPI - Refreshed token'));
                    credentials.accessToken = data.body.access_token;
                            credentials.expireTime = data.body.expires_in + Math.floor(new Date().getTime() / 1000);

                    RED.log.info(RED.log._('SpotifyAPI - Access token is ' + credentials.accessToken));
                    RED.log.info(RED.log._('SpotifyAPI - Expire time is ' + data.body.expires_in));
                    RED.nodes.addCredentials(ID, credentials);
                    },
                    function(err) {
                    RED.log.error(RED.log._('SpotifyAPI - Could not refresh the token!' + err.message));        
                    });
            } catch (err) {
                        RED.log.error(RED.log._('SpotifyAPI - ' + err));
                clearInterval(interval);
                    }
        }, 1200000);

        creds.interval = String(interval);
        RED.nodes.addCredentials(ID, creds);
        }
    }

    RED.nodes.registerType("spotify-auth", AuthNode, {
        credentials: {
            name: { type: 'text' },
            clientId: { type: 'password' },
            clientSecret: { type: 'password' },
            accessToken: { type: 'password' },
            refreshToken: { type: 'password' },
            expireTime: { type: 'password' },
        interval: { type: 'password' }
        }
    });

    RED.httpAdmin.get('/spotify-credentials/auth', function (req, res) {
        if (!req.query.clientId || !req.query.clientSecret ||
            !req.query.id || !req.query.callback) {
            res.send(400);
            return;
        }

        const node_id = req.query.id;
        const credentials = {
            clientId: req.query.clientId,
            clientSecret: req.query.clientSecret,
            callback: req.query.callback
        };
        const scope = req.query.scope;
        const csrfToken = crypto.randomBytes(18).toString('base64').replace(/\//g, '-').replace(/\+/g, '_');
        credentials.csrfToken = csrfToken;

        res.redirect(url.format({
            protocol: 'https',
            hostname: 'accounts.spotify.com',
            pathname: '/authorize',
            query: {
                client_id: credentials.clientId,
                response_type: 'code',
                redirect_uri: credentials.callback,
                state: node_id + ':' + csrfToken,
                show_dialog: true,
                scope: scope
            }
        }));
        RED.nodes.addCredentials(node_id, credentials);
    });

    RED.httpAdmin.get('/spotify-credentials/auth/callback', function (req, res) {
        if (req.query.error) {
            return res.send('spotify.query.error', { error: req.query.error, description: req.query.error_description });
        }

        const state = req.query.state.split(':');
        const node_id = state[0];
        const credentials = RED.nodes.getCredentials(node_id);

        if (!credentials || !credentials.clientId || !credentials.clientSecret) {
            return res.send('spotify.error.no-credentials');
        }
        if (state[1] !== credentials.csrfToken) {
            return res.send('spotify.error.token-mismatch');
        }

        const spotifyApi = new SpotifyWebApi({
            clientId: credentials.clientId,
            clientSecret: credentials.clientSecret,
            redirectUri: credentials.callback
        });

        spotifyApi.authorizationCodeGrant(req.query.code).then(data => {
        credentials.accessToken = data.body.access_token;
            credentials.refreshToken = data.body.refresh_token;
            credentials.expireTime = data.body.expires_in + Math.floor(new Date().getTime() / 1000);
            credentials.tokenType = data.body.token_type;
            credentials.name = 'Spotify OAuth2';

            delete credentials.csrfToken;
            delete credentials.callback;
            RED.nodes.addCredentials(node_id, credentials);
            res.send('spotify.authorized');
        RED.log.info(RED.log._('SpotifyAPI - Access token is ' + credentials.accessToken));
        RED.log.info(RED.log._('SpotifyAPI - Refresh token is ' + credentials.refreshToken));
        RED.log.info(RED.log._('SpotifyAPI - Expire time is ' + data.body.expires_in));
        })
        .catch(error => {
            res.send('spotify.error.tokens');
        });
    });
};

spotify.js

module.exports = function (RED) {
    const SpotifyWebApi = require('spotify-web-api-node');

    function SpotifyNode(config) {
        RED.nodes.createNode(this, config);

        const node = this;
        node.config = RED.nodes.getNode(config.auth);
        node.api = config.api;  

        node.on('input', function (msg) {
                handleInput(msg);

        });

        function handleInput(msg) {
            try {
                const credentials = RED.nodes.getCredentials(config.auth);

        const spotifyApi = new SpotifyWebApi({
                    clientId: credentials.clientId,
                    clientSecret: credentials.clientSecret,
                    accessToken: credentials.accessToken,
                    refreshToken: credentials.refreshToken
            });

        let params = (msg.params) ? msg.params : [];
                // Reduce params to 1 less than the function expects, as the last param is the callback
                params = params.slice(0, spotifyApi[node.api].length - 1);

                spotifyApi[node.api](...params).then(data => {
                    msg.payload = data.body;
                    node.send(msg);
                }).catch(err => {
                    msg.error = err;
                    node.send(msg);
                });
            } catch (err) {
                msg.err = err;
                node.send(msg);
            }
        }
    }
    RED.nodes.registerType("spotify", SpotifyNode);

    RED.httpAdmin.get('/spotify/apis', function (req, res) {
        const nonPublicApi = [
            '_getCredential',
            '_resetCredential',
            '_setCredential',
            'authorizationCodeGrant',
            'clientCredentialsGrant',
            'createAuthorizeURL',
            'getAccessToken',
            'getClientId',
            'getClientSecret',
            'getCredentials',
            'getRedirectURI',
            'getRefreshToken',
            'refreshAccessToken',
            'resetAccessToken',
            'resetClientId',
            'resetClientSecret',
            'resetCredentials',
            'resetRedirectURI',
            'resetRefreshToken',
            'setAccessToken',
            'setClientId',
            'setClientSecret',
            'setCredentials',
            'setRedirectURI',
            'setRefreshToken'
        ];

        let response = [];
        for (let key in Object.getPrototypeOf(new SpotifyWebApi())) {
            response.push(key);
        }
        response.sort();

        response = response.filter(function (item) {
            return nonPublicApi.indexOf(item) == -1;
        });

        res.json(response);
    });
};

For some explaination, i'v created an setinterval that trigger every 20min (1h token). Added a check for not creating multiple setinterval when deploying multiple time. In this interval i get the stored credentials, call refreshAccessToken function (with some logs) and save the credentials.

Then in the other node i've removed the old refresh token function and get the credentials from the auth node instead.

jeroenhe commented 5 years ago

I see @pckhib seems busy and is inactive at github for quite some time now, so this could take a while. Maybe we can fork it and create an improved version with token renewal in de meant time? We can always merge it back later...

praul commented 5 years ago

Oops, since I seem to have missed this issue.. I created a workaround, too. It is far from elegant, but a bit shorter than the solution above. Works perfectly. Below is my original issue, I'm closing that one now

Hi, first of all, thank you for this contribution. I had some problems, keeping the spotify control responsive over longer time of inactivity. Somehow the token refreshing did not fire at the right times or at all. It is a bit strange, since I could observe the token refresh happening. But when I didn't send input for a longer time, it did not refresh right. Anyhow, after an hour of inactivity, I always got WebapiError: Unauthorized.

I somehow butchered a second check mechanism into handleInput. It is far from elegant, but it works stable now for days. Maybe you can find a more elegant solution, if you are able to reproduce the problem.

function handleInput(msg) {
    try {
        let params = (msg.params) ? msg.params : [];
        // Reduce params to 1 less than the function expects, as the last param is the callback
        params = params.slice(0, spotifyApi[node.api].length - 1);

        spotifyApi[node.api](...params).then(data => {
            msg.payload = data.body;
            node.errorcount = 0;
            node.send(msg);

        }).catch(err => {
            if (err == 'WebapiError: Unauthorized') {
                node.errorcount++;
                node.error(node.errorcount);
                if (node.errorcount < 3) {
                    refreshToken().then(() => {
                        setTimeout(function(){ handleInput(msg); }, 500);
                    });
                } else {
                    msg.error = err;
                    err = NaN;
                } 

            } else 
                msg.error = err;
                err = NaN

            node.send(msg);
        });
    } catch (err) {
        msg.error = err;
        node.send(msg);              
    }

}

I also declared errorcount on line 10 of spotify.js node.errorcount = 0;

i8beef commented 5 years ago

Someone on Reddit asked about this and I thought I'd try to be helpful. I suspect your issue is right here: https://github.com/pckhib/node-red-contrib-spotify/blob/master/src/spotify.js#L19

If node.config.credentials.expireTime is always undefined here, refreshToken never gets called (greater than comparisons to undefined == false). That is, I suspect there's an issue with how this node is caching stuff between inputs... unfortunately I couldn't follow exactly how the author is trying to use the config pieces here, but hopefully if someone with more of a reason to get this working comes along this will help them pinpoint an issue more quickly for you guys.

I'll note though, the way the author implemented the refresh logic is the "correct" way to do it, its just something weird with the way he's doing this expiration check. Good luck!

Edit: I actually suspect this line is wrong: https://github.com/pckhib/node-red-contrib-spotify/blob/master/src/spotify.js#L54

Based on the nodered docs I've read I THINK you're supposed to pass the node's id that you want to set here, not the property you want to set? Maybe just replacing config.auth in that line with node.id?

gitbock commented 11 months ago

Oops, since I seem to have missed this issue.. I created a workaround, too. It is far from elegant, but a bit shorter than the solution above. Works perfectly. Below is my original issue, I'm closing that one now

Hi, first of all, thank you for this contribution. I had some problems, keeping the spotify control responsive over longer time of inactivity. Somehow the token refreshing did not fire at the right times or at all. It is a bit strange, since I could observe the token refresh happening. But when I didn't send input for a longer time, it did not refresh right. Anyhow, after an hour of inactivity, I always got WebapiError: Unauthorized.

I somehow butchered a second check mechanism into handleInput. It is far from elegant, but it works stable now for days. Maybe you can find a more elegant solution, if you are able to reproduce the problem.

function handleInput(msg) {
    try {
        let params = (msg.params) ? msg.params : [];
        // Reduce params to 1 less than the function expects, as the last param is the callback
        params = params.slice(0, spotifyApi[node.api].length - 1);

        spotifyApi[node.api](...params).then(data => {
            msg.payload = data.body;
            node.errorcount = 0;
            node.send(msg);

        }).catch(err => {
            if (err == 'WebapiError: Unauthorized') {
                node.errorcount++;
                node.error(node.errorcount);
                if (node.errorcount < 3) {
                    refreshToken().then(() => {
                        setTimeout(function(){ handleInput(msg); }, 500);
                    });
                } else {
                    msg.error = err;
                    err = NaN;
                } 

            } else 
                msg.error = err;
                err = NaN

            node.send(msg);
        });
    } catch (err) {
        msg.error = err;
        node.send(msg);              
    }

}

I also declared errorcount on line 10 of spotify.js node.errorcount = 0;

You are my hero :) I had a similar issue: First time the flow started, it did not work. The Error returned by the API is always "WebapiRegularError: Not found". When executed the same flow after 10 seconds it worked perfectly.

I was able to fix my issue by changing the catch statement of your suggested code

if (err == 'WebapiError: Unauthorized' || err.toString().startsWith('WebapiRegularError')) {

Thanks a lot!