PHP-Open-Source-Saver / jwt-auth

🔐 JSON Web Token Authentication for Laravel & Lumen
MIT License
729 stars 113 forks source link

How to refresh expired token #215

Open mira-thakkar opened 1 year ago

mira-thakkar commented 1 year ago

Couldn't refresh the token for expired token from refresh api

I am following the exact steps from documentation to implement the Refresh token flow. But refersh api returns 401 status for the expired token(Non-expired token works fine), but i suppose refresh_ttl makes sense to refresh expired token

While debugging, I see that it's not going upto controller, but from middleware it gives 401 . I wonder that we're not using any specific middleware for refresh route, so how come package knows that for refresh route, Authenticate middleware will accept expired token, but for other routes, not ?

Your environment:

Q A
Bug yes
Framework Laravel
Framework version 9.42.2
Package version 1.x.y
PHP version 8.1.0

Steps to reproduce

Implement the package as specified in Quick Start, and call refresh api with expired token

Expected behaviour

refresh api should be able to send new token in exchange of expired token

Actual behaviour

refresh api returns 401 response

zerossB commented 1 year ago

I found a solution to this problem.

In the documentation it asks to put the default guard as API.

// config/auth.php

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

'guards' => [
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

But for some reason when we login using the auth() helper it does not implicitly look for the default guard previously defined in config/auth.php

For that, I made the guard I want explicit in the Login and Refresh route (in the case of the api documentation)

For example:

API code

/**
 * Get a JWT via given credentials.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function login()
{
    $credentials = request(['email', 'password']);

    if (! $token = auth()->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $this->respondWithToken($token);
}

...

/**
 * Refresh a token.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function refresh()
{
    return $this->respondWithToken(auth()->refresh());
}

Code making the guard explicit:

/**
 * Get a JWT via given credentials.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function login()
{
    $credentials = request(['email', 'password']);

    if (! $token = auth('api')->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $this->respondWithToken($token);
}

...

/**
 * Refresh a token.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function refresh()
{
    return $this->respondWithToken(auth('api')->refresh());
}

Note that I left the auth('api') explicit, doing so worked for me with the refresh token.

Messhias commented 1 year ago

I found a solution to this problem.

In the documentation it asks to put the default guard as API.

// config/auth.php

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

'guards' => [
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

But for some reason when we login using the auth() helper it does not implicitly look for the default guard previously defined in config/auth.php

For that, I made the guard I want explicit in the Login and Refresh route (in the case of the API documentation)

For example:

API code

/**
 * Get a JWT via the given credentials.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function login()
{
    $credentials = request(['email', 'password']);

    if (! $token = auth()->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $this->respondWithToken($token);
}

...

/**
 * Refresh a token.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function refresh()
{
    return $this->respondWithToken(auth()->refresh());
}

Code making the guard explicit:

/**
 * Get a JWT via the given credentials.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function login()
{
    $credentials = request(['email', 'password']);

    if (! $token = auth('api')->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $this->respondWithToken($token);
}

...

/**
 * Refresh a token.
 *
 * @return \Illuminate\Http\JsonResponse
 */
public function refresh()
{
    return $this->respondWithToken(auth('api')->refresh());
}

Note that I left the auth('api') explicit, doing so worked for me with the refresh token.

The default auth helper for some reason in Laravel is always simplicity to the web guard.

Also, It'll be more helpful if we have this written down in the documentation, would you like to contribute to adding this info to the documentation?

mira-thakkar commented 1 year ago

@Messhias @zerossB i tried the way you said to specify guard in auth helper. It works well for non-expired token, but it doesn't work on my side for EXPIRED TOKEN. The refresh route just returns from the middleware giving 401 error. I am attaching my code here to make it more understandable

Controller code

 public function login(Request $request)
  {
      $user = User::find(1); // This user comes from google signin, keeping it shorter here to understand
      $token = \auth('api')->login($user);
      return $this->respondWithToken($token);
  }

  public function refresh(Request $request)
  {
      logger()->info('refresh controller',[$request->header('Authorization')]);
      $token = \auth('api')->refresh();
     return $this->respondWithToken($token);
  }

routes/api.php

Route::post('login', [SocialAuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
    Route::post('refresh', [SocialAuthController::class,'refresh']);
});

For the expired token, the log specified in the refresh method is not logged, so it's not that token is failed to refresh, but it doesn't reach upto that line.

I am curious to know if there is something about middleware that i am missing.

zerossB commented 1 year ago

The default auth helper for some reason in Laravel is always simplicity to the web guard.

Also, It'll be more helpful if we have this written down in the documentation, would you like to contribute to adding this info to the documentation?

Of course! I can contribute! I'll finalize the help for @mira-thakkar and then summarize for the official documentation.

zerossB commented 1 year ago

@Messhias @zerossB i tried the way you said to specify guard in auth helper. It works well for non-expired token, but it doesn't work on my side for EXPIRED TOKEN. The refresh route just returns from the middleware giving 401 error. I am attaching my code here to make it more understandable

Controller code

 public function login(Request $request)
  {
      $user = User::find(1); // This user comes from google signin, keeping it shorter here to understand
      $token = \auth('api')->login($user);
      return $this->respondWithToken($token);
  }

  public function refresh(Request $request)
  {
      logger()->info('refresh controller',[$request->header('Authorization')]);
      $token = \auth('api')->refresh();
     return $this->respondWithToken($token);
  }

routes/api.php

Route::post('login', [SocialAuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
    Route::post('refresh', [SocialAuthController::class,'refresh']);
});

For the expired token, the log specified in the refresh method is not logged, so it's not that token is failed to refresh, but it doesn't reach upto that line.

I am curious to know if there is something about middleware that i am missing.

That's strange, I tested it here with the changes you commented on and it worked here. There must be something missing or something like that.

I'm going to send here the files that I changed for you to compare with yours, ok?


// routes/api.php

Route::middleware('api')->prefix('auth')->group(function () {
    Route::post('login', 'AuthController@login');
    Route::post('logout', 'AuthController@logout');
    Route::post('refresh', 'AuthController@refresh');
    Route::post('me', 'AuthController@me');
});
// App\Providers\RouteServiceProvider

protected $namespace = "App\\Http\\Controllers";

/**
 * Define your route model bindings, pattern filters, and other route configuration.
 */
public function boot(): void
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::middleware('api')
            ->prefix('api')
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));

        Route::middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    });
}
// App\Http\Controllers\AuthController

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Request;

class AuthController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth:api', ['except' => ['login']]);
    }

    public function login(Request $request)
    {
        $user = User::find(1);
        $token = auth('api')->login($user);
        return $this->respondWithToken($token);
    }

    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60
        ]);
    }

    public function me()
    {
        return response()->json(auth('api')->user());
    }

    public function logout()
    {
        auth('api')->logout();
        return response()->json(['message' => 'Successfully logged out']);
    }

    public function refresh(Request $request)
    {
        $token = auth('api')->refresh();
        return $this->respondWithToken($token);
    }
}
// config/auth.php

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],
Messhias commented 1 year ago

@Messhias @zerossB i tried the way you said to specify guard in auth helper. It works well for non-expired token, but it doesn't work on my side for EXPIRED TOKEN. The refresh route just returns from the middleware giving 401 error. I am attaching my code here to make it more understandable Controller code

 public function login(Request $request)
  {
      $user = User::find(1); // This user comes from google signin, keeping it shorter here to understand
      $token = \auth('api')->login($user);
      return $this->respondWithToken($token);
  }

  public function refresh(Request $request)
  {
      logger()->info('refresh controller',[$request->header('Authorization')]);
      $token = \auth('api')->refresh();
     return $this->respondWithToken($token);
  }

routes/api.php

Route::post('login', [SocialAuthController::class, 'login']);
Route::middleware('auth:api')->group(function () {
    Route::post('refresh', [SocialAuthController::class,'refresh']);
});

For the expired token, the log specified in the refresh method is not logged, so it's not that token is failed to refresh, but it doesn't reach upto that line. I am curious to know if there is something about middleware that i am missing.

That's strange, I tested it here with the changes you commented on and it worked here. There must be something missing or something like that.

I'm going to send here the files that I changed for you to compare with yours, ok?

// routes/api.php

Route::middleware('api')->prefix('auth')->group(function () {
    Route::post('login', 'AuthController@login');
    Route::post('logout', 'AuthController@logout');
    Route::post('refresh', 'AuthController@refresh');
    Route::post('me', 'AuthController@me');
});
// App\Providers\RouteServiceProvider

protected $namespace = "App\\Http\\Controllers";

/**
 * Define your route model bindings, pattern filters, and other route configuration.
 */
public function boot(): void
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::middleware('api')
            ->prefix('api')
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));

        Route::middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    });
}
// App\Http\Controllers\AuthController

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Request;

class AuthController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth:api', ['except' => ['login']]);
    }

    public function login(Request $request)
    {
        $user = User::find(1);
        $token = auth('api')->login($user);
        return $this->respondWithToken($token);
    }

    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60
        ]);
    }

    public function me()
    {
        return response()->json(auth('api')->user());
    }

    public function logout()
    {
        auth('api')->logout();
        return response()->json(['message' => 'Successfully logged out']);
    }

    public function refresh(Request $request)
    {
        $token = auth('api')->refresh();
        return $this->respondWithToken($token);
    }
}
// config/auth.php

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

Did it solved your issue?

mira-thakkar commented 1 year ago

Sorry, for taking long to get back to this But Unfortunately, no @Messhias

@zerossB i used the exact same code as you specified here, but no luck. also i wonder we're specifying api middleware twice[ RouteServiceProvider & routes/api.php], any specific reason behind that? i want to give details about my jwt configs as well. As of now for testing purpose i use JWT_TTL = 5 (mins) JWT_REFRESH_TTL = 1440 (mins) so when i try to make refresh call with the token after 5 mins, it gives me 401

Messhias commented 1 year ago

Sorry, for taking long to get back to this But Unfortunately, no @Messhias

@zerossB i used the exact same code as you specified here, but no luck. also i wonder we're specifying api middleware twice[ RouteServiceProvider & routes/api.php], any specific reason behind that? i want to give details about my jwt configs as well. As of now for testing purpose i use JWT_TTL = 5 (mins) JWT_REFRESH_TTL = 1440 (mins) so when i try to make refresh call with the token after 5 mins, it gives me 401

Ok, waiting for the details.

larswoltersdev commented 1 year ago

Experiencing the same issue by reproducing the above code.

Found solution: remove the auth:api middleware from the refresh endpoint.

If you put the auth:api middleware on the refresh route, Laravel would try to authenticate the incoming request, and if the token is expired, the middleware would block the request and return an "Unauthenticated" response.

iqbalatma commented 1 year ago

when login, i send back 2 type token, access token (with short TTL) and refresh token (with long TTL). I also add custom claim on access and refresh, so when access token invalid, frontend can send request to refresh token, and they will get new access and refresh token. but this mechanism need to custom by myself. for blacklist token i using this approach https://dev.to/webjose/how-to-invalidate-jwt-tokens-without-collecting-tokens-47pk