7

I have implemented JWT for user login in my app (before Spotify Auth), like so:

Flask

@auth_blueprint.route('/auth/login', methods=['POST'])
def login_user():
    # get post data
    post_data = request.get_json()
    response_object = {
        'status': 'fail',
        'message': 'Invalid payload.'
    }
    if not post_data:
        return jsonify(response_object), 400
    email = post_data.get('email')
    password = post_data.get('password')
    try:
        # fetch the user data
        user = User.query.filter_by(email=email).first()
        if user and bcrypt.check_password_hash(user.password, password):
            auth_token = user.encode_auth_token(user.id)
            if auth_token:
                response_object['status'] = 'success'
                response_object['message'] = 'Successfully logged in.'
                response_object['auth_token'] = auth_token.decode()
                return jsonify(response_object), 200
        else:
            response_object['message'] = 'User does not exist.'
            return jsonify(response_object), 404
    except Exception:
        response_object['message'] = 'Try again.'
        return jsonify(response_object), 500

These are the methods of my SQLAlchemy User(db.Model)

def encode_auth_token(self, user_id):
        """Generates the auth token"""
        try:
            payload = {
                'exp': datetime.datetime.utcnow() + datetime.timedelta(
                    days=current_app.config.get('TOKEN_EXPIRATION_DAYS'), 
                    seconds=current_app.config.get('TOKEN_EXPIRATION_SECONDS')
                ),
                'iat': datetime.datetime.utcnow(),
                'sub': user_id
            }
            return jwt.encode(
                payload,
                current_app.config.get('SECRET_KEY'),
                algorithm='HS256'
            )
        except Exception as e:
            return e

@staticmethod
def decode_auth_token(auth_token):
        """
        Decodes the auth token - :param auth_token: - :return: integer|string
        """
        try:
            payload = jwt.decode(
                auth_token, current_app.config.get('SECRET_KEY'))
            return payload['sub']
        except jwt.ExpiredSignatureError:
            return 'Signature expired. Please log in again.'
        except jwt.InvalidTokenError:
            return 'Invalid token. Please log in again.'

React

App.jsx

  loginUser(token) {
    window.localStorage.setItem('authToken', token);
    this.setState({ isAuthenticated: true });
    this.getUsers();
    this.createMessage('Welcome', 'success');
  };

(...)

<Route exact path='/login' render={() => (
  <Form
    isAuthenticated={this.state.isAuthenticated}
    loginUser={this.loginUser}
  />
)} />

and

Form.jsx

handleUserFormSubmit(event) {
    event.preventDefault();
    const data = {
      email: this.state.formData.email,
      password: this.state.formData.password
    };
    const url = `${process.env.REACT_APP_WEB_SERVICE_URL}/auth/${formType.toLowerCase()}`;
    axios.post(url, data)
      .then((res) => {
        this.props.loginUser(res.data.auth_token);
    })

Third Party Authorization + Second App Authentication

Now I'd like to add a second layer of authentication and handle tokens after Spotify callback, like so:

@spotify_auth_bp.route("/callback", methods=['GET', 'POST'])
def spotify_callback():

    # Auth Step 4: Requests refresh and access tokens
    SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"

    CLIENT_ID =   os.environ.get('SPOTIPY_CLIENT_ID')
    CLIENT_SECRET = os.environ.get('SPOTIPY_CLIENT_SECRET')
    REDIRECT_URI = os.environ.get('SPOTIPY_REDIRECT_URI')

    auth_token = request.args['code']

    code_payload = {
        "grant_type": "authorization_code",
        "code": auth_token,
        "redirect_uri": REDIRECT_URI,
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
    }

    post_request = requests.post(SPOTIFY_TOKEN_URL, data=code_payload)

    # Auth Step 5: Tokens are Returned to Application
    response_data = json.loads(post_request.text)

    access_token = response_data["access_token"]
    refresh_token = response_data["refresh_token"]
    token_type = response_data["token_type"]
    expires_in = response_data["expires_in"]

    # At this point, there is to generate a custom token for the frontend
    # Either a self-contained signed JWT or a random token?
    # In case the token is not a JWT, it should be stored in the session (in case of a stateful API)
    # or in the database (in case of a stateless API)
    # In case of a JWT, the authenticity can be tested by the backend with the signature so it doesn't need to be stored at all?

    res = make_response(redirect('http://localhost/about', code=302))

    return res

Note: this a possible endpoint for getting new Spotify tokens:

@spotify_auth_bp.route("/refresh_token", methods=['GET', 'POST'])
def refresh_token():
        SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
        CLIENT_ID =   os.environ.get('SPOTIPY_CLIENT_ID')
        CLIENT_SECRET = os.environ.get('SPOTIPY_CLIENT_SECRET')

        code_payload = {
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
        }

        encode = 'application/x-www-form-urlencoded'
        auth = base64.b64encode("{}:{}".format(CLIENT_ID, CLIENT_SECRET).encode())
        headers = {"Content-Type" : encode, "Authorization" : "Basic {}".format(auth)} 

        post_request = requests.post(SPOTIFY_TOKEN_URL, data=code_payload, headers=headers)
        response_data = json.loads(post_request.text)

        access_token = response_data["access_token"]
        refresh_token = response_data["refresh_token"]
        token_type = response_data["token_type"]
        expires_in = response_data["expires_in"]

        return access_token

What is the best way of handling my tokens after Spotify callback?

Considering that, once user is logged with the app, he will also be logged with Spotify non-stop, having to refresh Spotify's access token every 60 minutes:

  • Is Authorization Code a server-to-server flow only to protect secret app credentials, and then it is safe to have tokens at frontend?

  • Should I keep both Access token and refresh tokens stored at frontend, and have a Stateless JWT?

  • Should I keep only temporary access token and keep refresh tokens at database, having a Stateful JWT?

  • Should I opt for a Session, persisted only server-side, instead?

What is the safest way of handling my sensitive data here? And, considering the code above, how so?

8-Bit Borges
  • 9,643
  • 29
  • 101
  • 198

1 Answers1

6

A huge number of questions here! Let's take them one by one:

Is Authorization Code a server-to-server flow only to protect secret app credentials, and then it is safe to have tokens at frontend?

In the Authorization Code grant, you have to exchange the Authorization Code for a token. This is done with a request to /token (grant_type: authorization_code) and it requires your client_id and client_secret which is secretly stored in your server (aka not-public in your react web app). In this context it's indeed server-to-server.

Should I keep both Access token and refresh tokens stored at frontend, and have a Stateless JWT?

In your case, I would say no. If the token will be used to do some API request to Spotify on server-side, please keep access_token and refresh_token server-side.

But then, it's not anymore stateless ? Indeed.

What could you do "stateless" ?

If you really want/need stateless tokens, IMHO you could store the access_token in a Cookie with following options (and it's mandatory):

  • Secure: cookies only sent on HTTPS
  • HttpOnly: not accessible from Javascript
  • SameSite: preferrably strict! (here it depends if you need CORS)

PRO:

  • It's stateless

CON:

  • It might be a huge cookie.
  • Anyone which access your computer can get the access_token, just like a session cookie. Expiration time is important here. See also: https://stackoverflow.com/a/41076836/2437450
  • Something else ???? To be challenged.

The case of refresh_token.

I would recommend to store refresh tokens server-side because it's usually a long-life token.

What to do when the access_token expire ?

When a request comes with an expired access_token, you can simply refresh the access_token with server-side-stored refresh_token, do the job, and return the response with a new access_token stored through Set-Cookie header.

Additional note about JWT

If you always have JWT and you store them in Http-Only cookies, you'll probably say that you don't have any way to know if your are logged-in from your React app. Well there is a trick I already experimented with JWT which is pretty nice.

A JWT is composed of 3 parts; the header, the payload and the signature. What you actually want to protect in your cookies is the signature. Indeed, if you don't have the right signature the JWT is useless. So what you could do is to split the JWT and make only the signature Http-Only.

In your case it should look like:

@app.route('/callback')
def callback():
    # (...)

    access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJSYXBoYWVsIE1lZGFlciJ9.V5exVQ92sZRwRxKeOFxqb4DzWaMTnKu-VmhW-r1pg8E'

    a11n_h, a11n_d, a11n_s = access_token.split('.')

    response = redirect('http://localhost/about', 302)
    response.set_cookie('a11n.h', a11n_h, secure=True)
    response.set_cookie('a11n.d', a11n_d, secure=True)
    response.set_cookie('a11n.s', a11n_s, secure=True, httponly=True)

    return response

You would have 3 cookies:

  • a11n.h: the header (options: Secure)
  • a11n.d: the payload (options: Secure)
  • a11n.s: the signature (options: Secure, Http-Only)

The consequence is:

  • a11n.d cookie is accessible from your React app (you can even get userinfo from it)
  • a11n.s cookie is not accessible from Javascript
  • You have to reassemble the access_token from cookies on server-side before sending request to Spotify

To reassemble the access_token:

@app.route('/resource')
def resource():
    a11n_h = request.cookies.get('a11n.h') 
    a11n_d = request.cookies.get('a11n.d')
    a11n_s = request.cookies.get('a11n.s')

    access_token = a11n_h + '.' + a11n_d + '.' + a11n_s
    jwt.decode(access_token, verify=True)

I hope it helps!

Disclaimer:

Code samples need to be improved (error handling, checks, etc). They are only examples to illustrate the flow.

Raphael Medaer
  • 2,528
  • 12
  • 18
  • thanks a LOT for your detailed analysis. sorry for the extensive question, but it really comes down to pros and cons. I would go with your JWT solution. could you please edit your answer and provide some code, considering my code above, with your implementation on JWT? for the sake of completeness also, since I have JWT code on the question. I would really appreciate that and upvote your answer. – 8-Bit Borges Mar 31 '20 at 00:55
  • I see that your access token is already encoded when you set it as a cookie. how do I get there? – 8-Bit Borges Mar 31 '20 at 01:18
  • also, I also must set refresh tokens as cookie, no? – 8-Bit Borges Mar 31 '20 at 01:20
  • From your code: ``` return jwt.encode( payload, current_app.config.get('SECRET_KEY'), algorithm='HS256' ) ``` – Raphael Medaer Mar 31 '20 at 01:20
  • For the refresh token; see "The case of refresh_token" in my answer. – Raphael Medaer Mar 31 '20 at 01:21
  • "simply refresh the access_token with server-side-stored refresh_token". where and how should I store it as a cookie? at `/callback`? – 8-Bit Borges Mar 31 '20 at 01:24
  • No. `refresh_token` should be stored somewhere on your server, for instance in a database. Not as a cookie. A cookie is client-side storage. – Raphael Medaer Mar 31 '20 at 01:25
  • sure...I mentioned that in the question. the problem is that I get refresh tokens from `/callback`, and theres no way, to my knowledge, to query the database passing id=user_id, because spotify server accepts no `/callback/` as url. I had tried this and faced a dead end. – 8-Bit Borges Mar 31 '20 at 01:30
  • You should have a look to `state`! From Spotify: Optional, but strongly recommended. The state can be useful for correlating requests and responses. – Raphael Medaer Mar 31 '20 at 01:32