laravel / passport

Laravel Passport provides OAuth2 server support to Laravel.
https://laravel.com/docs/passport
MIT License
3.29k stars 780 forks source link

CreateFreshApiToken not always setting 'laravel_token' #564

Closed dschreij closed 6 years ago

dschreij commented 7 years ago

Laravel 5.5 Passport 4.0.2

I'm struggling with the peculiar situation that the CreateFreshApiToken middleware does not always set a laravel_token cookie after a successful login, resulting in 401 - Unauthorized failures afterwards. On my local server everything works fine, but on the production server, having the exact same code, it doesn't. I'm building an SPA using Vue.js, so all the login requests are performed asynchronously using axios.

My problem is similar to https://github.com/laravel/passport/issues/400, but I'm just using the normal Auth scaffolding functions for logging in, instead of oauth functions.

Just to give a complete picture, here are my relevant functions and settings:

The CreateFreshApiToken is at the end of my Kernel's web-middleware array:

// app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        // \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
        \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
    ],

    'api' => [
        'throttle:60,1',
        'bindings',
    ],
];

The relevant methods of my login controller are as follows:

// app/Http/Controllers/Auth/LoginController.php

class LoginController extends Controller
{
    ...
    public function __construct()
    {
        // $this->middleware('guest')->except('logout');
    }

    /**
     * The user has been authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  mixed  $user
     * @return mixed
     */
    protected function authenticated(\Illuminate\Http\Request $request, $user)
    {
        $user->last_login = \Carbon\Carbon::now();
        $user->timestamps = false;
        $user->save();
        $user->timestamps = true;

        return (new UserResource($user))->additional([
            'permissions' => $user->getUIPermissions()
        ])->response();
    }

    public function refreshCsrfToken(){
        return response()->json(['csrfToken' => csrf_token()]);
    }

    public function logout(\Illuminate\Http\Request $request)
    {
        $this->guard()->logout();
        $request->session()->invalidate();
    }
}

So the authenticated() method returns a Response instance with the user information attached in Json format. The routes are set up correctly:

// routes/web.php
...
Route::post('login', 'Auth\LoginController@login');
Route::post('logout', 'Auth\LoginController@logout');
// Get the current CSRF token to use.
Route::get('refreshtokens', 'Auth\LoginController@refreshCsrfToken');
...

My Vue code for authenticating the user is as follows:

methods:{
    login(){
        // Copy the user object
        const data = {...this.user};
        // If remember is false, don't send the parameter to the server
        if(data.remember === false){
            delete data.remember;
        }

        this.authenticating = true;
        // Use vuex actions to authenticate. This simply posts the data to the /login route.
        this.authenticate(data)
            .then( () => setTimeout(this.refreshTokens, 500) )
            .catch( error => {
                this.authenticating = false;
                if(error.response && [422, 423].includes(error.response.status) ){
                    this.validationErrors = error.response.data.errors;
                    this.showErrorMessage(error.response.data.message);
                }else{
                    this.showErrorMessage(error.message);  
                }
            });
    },
    refreshTokens(){
        return new Promise((resolve, reject) => {
            axios.get('/refreshtokens')
                .then( response => {
                    window.Laravel.csrfToken = response.data.csrfToken;
                    window.axios.defaults.headers.common['X-CSRF-TOKEN'] = response.data.csrfToken;
                    this.authenticating = false;
                    this.$router.replace(this.$route.query.redirect || '/');
                    return resolve(response);
                })
                .catch( error => {
                    this.showErrorMessage(error.message);
                    reject(error);
                });
        });
    },
    ...
}

I'm already aware that the laravel_token cookie is not set by the POST request to /login, so I perform another dummy GET request to /refreshTokens afterwards. I also do this after a timeout of 500ms to give the server some time to get its things in order (I thought that would matter, but it didn't).

On both servers, the login function returns with a 200 response when entering the correct credentials

POST to /login

Local response headers

General:

Request URL:http://app.lan/login
Request Method:POST
Status Code:200 OK
Remote Address:127.0.0.1:80
Referrer Policy:no-referrer-when-downgrade

Response headers:

Cache-Control:no-cache, private
Connection:Keep-Alive
Content-Length:552
Content-Type:application/json
Date:Wed, 08 Nov 2017 09:51:43 GMT
Keep-Alive:timeout=5, max=100
Server:Apache/2.4.18 (Ubuntu)
Server-Timing:app=70.348024368286; "Application", db=5.43; "Database", timeline-event-total=70.429086685181; "Total execution time.", timeline-event-initialisation=3.6990642547607; "Application initialisation.", timeline-event-boot=3.2789707183838; "Framework booting.", timeline-event-run=66.73002243042; "Framework running."
Set-Cookie:laravel_session=eyJpdiI6IkpjVGZZUk1LZWVvRUhcL1g4MGNSU2hBPT0iLCJ2YWx1ZSI6InpYdE4rXC9PbFI1c1loVzRmZTFLc0JQaHBjNmpNN3Q2VE1sUXZXRG5VMFZOdk56Y3MzakFOWkdmWFNNNnNYN2pwQklEa3J2Qjl1WDJRY0VIdmdWSlZJQT09IiwibWFjIjoiZTBmMWZmMjQ2NTY2OWE2NzA2OWMzNGY0MTc4YjhmNmI1ZDlmNWUxNTkxYmQ4MjYwOGJhNzU2ZDc4OWMzYjYwOCJ9; expires=Wed, 08-Nov-2017 11:51:43 GMT; Max-Age=7200; path=/; HttpOnly
Set-Cookie:remember_web_59ba36addc2b2f9401580f014c7f58ea4e30989d=eyJpdiI6ImY4aWpGVHJLVEQ2V1wvenA5RjBFeVV3PT0iLCJ2YWx1ZSI6Ild0WXBOanNJcEtvTDlPaEVreGU0SzFjRG1MUDU4VEtZTVlVcktJSk1cL1h5dzRsUE4wWWdvVTNRNUhZRFhFR2dQa0tUNGZ3Z0dlUDI5UHgwaUlsRTRIYitmMWZTMWlhU0JBQ0dlNzh1ZHB2YytwVVhuOXZza0hUbE1SbG1jQ3R5VGlrZzhRSG56c0NDVmhsV21Qa0ozYmc0dEhRbk9HSFQ3K0cyYmpaWWRiTElyNUFkSGRWbGZtWEZxeEg2cG5vbzEiLCJtYWMiOiI0NTQ0MWQ5MjUxNmQ4YTFiNjZkM2U5ZDE3NGZkZjYzMWNlMjc3NzVmMDUxMzc1MTg4NjRlNDkxN2RmZDBkMmRlIn0%3D; expires=Mon, 07-Nov-2022 09:51:43 GMT; Max-Age=157680000; path=/; HttpOnly
Set-Cookie:XSRF-TOKEN=eyJpdiI6IkZ6TGsxVzR1STM1K2VXcDVGMmlUalE9PSIsInZhbHVlIjoiSjI2UnRYeFdtRUl3NmIxNW1FWEZIUE1HaHVYUWpFNExlNGxiaHptT0RPT0JTNmp2TVNoVFBTaFdwT3JMRTd6bVBEcTZ5SWIwTUVoeE1YQ202TzZCeFE9PSIsIm1hYyI6IjY1ODQ0ODAyNGIxNTA5MmRkNjczNjYyYzVjNGU1Y2NlYTY4ODE4YTAyNDJkNzE4MDBjODc4YmRkY2ZiMDg4ODMifQ%3D%3D; expires=Wed, 08-Nov-2017 11:51:43 GMT; Max-Age=7200; path=/
Strict-Transport-Security:max-age=63072000; includeSubdomains
X-Clockwork-Id:1510134703.2675.1598695226
X-Clockwork-Version:1.14.5
X-Content-Type-Options:nosniff
X-Frame-Options:DENY

Remote response headers

General:

Request URL:http://app.remote.net/login
Request Method:POST
Status Code:200 OK
Remote Address:xx.xx.xx.81:80
Referrer Policy:no-referrer-when-downgrade

Response headers:

Cache-Control:no-cache, private
Connection:Keep-Alive
Content-Length:590
Content-Type:application/json
Date:Wed, 08 Nov 2017 09:50:38 GMT
Keep-Alive:timeout=5, max=100
Server:Apache/2.4.18 (Ubuntu)
Set-Cookie:XSRF-TOKEN=eyJpdiI6IjI3NDBWcTZJd1Q4YjEzTFdFdDd4T1E9PSIsInZhbHVlIjoiTjFLSmRNcmFaaG8rcWJveEd1QTcwT2hJbVJXN0NzT215NWY1WE9uM25DZFwvXC9uZm10eXM2YTFmWDBYSU9NeWJJd21jS2VGQnZjclg0WUM4Q0ZiQ0k5UT09IiwibWFjIjoiN2M1YmMyNTU3ZGI0MzVhZTAxZDZhNzhkNzQxOWNlMGVhNmM3ZmFjMWI1NzQ5NDBlODU1NjY1OGY4OGNkNTliMiJ9; expires=Wed, 08-Nov-2017 11:50:39 GMT; Max-Age=7200; path=/
Set-Cookie:laravel_session=eyJpdiI6Imt6dGtjZUxZVGdvY0xKcUl5UFJ0dGc9PSIsInZhbHVlIjoiWXF2M1hZZDZMYTV1eTRiVGJBcEZESVBPVDI0cHhHUmdmR2s1dE1vSzRUQThiOWJmTCtFWnBYMnJJRFZyenBJUmRqUCt3aWVHSlh1NVoydWM5WlpaZEE9PSIsIm1hYyI6IjQ5NWM0ZDlmZjE0YzNjYmZhYzQzZTRlNzRkZTc3ZmNkYzI1ZDFjYmMwZTY4N2Y3OTQ3NDU5YmM0YTE4YTc5MDkifQ%3D%3D; expires=Wed, 08-Nov-2017 11:50:39 GMT; Max-Age=7200; path=/; HttpOnly
Set-Cookie:remember_web_59ba36addc2b2f9401580f014c7f58ea4e30989d=eyJpdiI6Imc4YnRBSk4yTjJWWHJNQjVmM0RVMXc9PSIsInZhbHVlIjoiV2k2NEEzeWpGT0RVcFhUS3hLRldjd3M3bGJRRG9nK1Z0SXplWjNRSzRuOEFnNk1cL3Jsb1lxMDdWR0Y1bHp1ZjVnMXlVUWtyRmZHcmtQYWJucjhYTEVvRmRFNGZoRTVhM3Fkb2s0OUFzcVFFXC9jc2h6dUVOSzNcL0YwNWxyWU9JZ1lqNjVRNlhUcWRnNU5hWXNXa0hiNHd0R0tLNWJcL1B4R2tldFNFaTNDWHR2eWc5SXRzSitocVFHYXJwejAxVFpVViIsIm1hYyI6ImQ3MTQ0ZmI5MzdhNzc3N2E5OTdhZDU4ODI0MDEyYWYwYmJmZDQ3NTRiOTNlZjI3M2U3MDNhNTFkMDg2ZTc3MmUifQ%3D%3D; expires=Mon, 07-Nov-2022 09:50:38 GMT; Max-Age=157679999; path=/; HttpOnly

After a succesful POST request to /login, both servers perform an extra GET request to /refreshTokens to get the laravel_token cookie. However, this cookie is not included in the response to the remote server:

GET to /refreshTokens

Local response:

General:

Request URL: http://app.lan/refreshtokens
Request Method:GET
Status Code:200 OK
Remote Address:127.0.0.1:80
Referrer Policy:no-referrer-when-downgrade

Response headers:

Cache-Control:no-cache, private
Connection:Keep-Alive
Content-Length:56
Content-Type:application/json
Date:Wed, 08 Nov 2017 09:51:43 GMT
Keep-Alive:timeout=5, max=99
Server:Apache/2.4.18 (Ubuntu)
Server-Timing:app=10.116100311279; "Application", db=0.85; "Database", timeline-event-total=10.226011276245; "Total execution time.", timeline-event-initialisation=2.6609897613525; "Application initialisation.", timeline-event-boot=3.1650066375732; "Framework booting.", timeline-event-run=7.5640678405762; "Framework running."
Set-Cookie:XSRF-TOKEN=eyJpdiI6InQ1NUFDSk1oOVZZcXlzNXBDRVhCRUE9PSIsInZhbHVlIjoiRU14U0tha2kzK2hXdXNzTFZCVElTMVBYNStyMEh4bGI0eXh5azdPU0tzUGY1bit0NGoyYTg4RlU3bDhUMnc2bnBRT2VCaVpsY3BXWERvTjJzNDlBUEE9PSIsIm1hYyI6IjRkYjRmOGQzODZmNDZmN2U4MDlmOTM1NTNlYzFjMWEzODgyNGY5ZDY5ZmU2YjRlNWIxYjFiMTA1NDg2YjlkYzIifQ%3D%3D; expires=Wed, 08-Nov-2017 11:51:43 GMT; Max-Age=7200; path=/
Set-Cookie:laravel_session=eyJpdiI6IkM5SGJaZnZNY0U1UEhWSFZVaGRiK1E9PSIsInZhbHVlIjoieERcL1lFdFwvVm9va3plWGVcL3pMVDdtNXdRVE9jUXEyaXpHZnJhTlAzQmg2b1NNc25iSXRIR1diRnBLVVwvbkpCS3lvXC9XY1NhcCtFUGhiYnB3Q0haeGxudz09IiwibWFjIjoiMDY5NmM2N2E5NmQ1MDk0MDg3ZjZkZGI5MDk4NWViNjg2Mjc2NzVjOGVjNjNiZjZmNDJmOTdhNjQxYTAzNWY4ZiJ9; expires=Wed, 08-Nov-2017 11:51:43 GMT; Max-Age=7200; path=/; HttpOnly
Set-Cookie:laravel_token=eyJpdiI6IlpITmVwRVZkN0ZGY0JQVGNreERiZlE9PSIsInZhbHVlIjoiY3o4VHprOHJhYlZLcDZEVm9NUlwvSXpDY1lxUXE2clpcL2F4ZnBnUXROQUVPRzdTTFk4QWV0YjY1ZnVyb1NGM3RkWGVYTWJlRWR5b2dyc1RSYkVoZGRtY3hRdklqMmdHNTBTQTBMWFp0TlRWZkhmYmU2dlBUUkY1TEwwbGd1aFpWZTJqZEdtQm5ha0hreVY5SWZpS1ljT3F5SURUZVZyM25DQnUyb0hqVWtHOU1FR2NBUU45d3hsQTFjeFVBRzUwQU9EM1c3SGs5XC9wOWUrVitIcDBycUY3WjdGVEhCRCs5K0g5bXRua0J2Z3V1azM3aW42Tk5QZWdUM3RoNmNqd1dwT0VGeXdDWE5pQWJMTWJKYXVldHFJelE9PSIsIm1hYyI6ImIxNTQyNTU2Y2YwYzM4MmIzNDk0NjBiOTQ5MDZkZmZkNWVmODMxMWY5Zjc4YTI4NGZjYzAzNzczMmY0OTBkMjkifQ%3D%3D; expires=Wed, 08-Nov-2017 11:51:43 GMT; Max-Age=7200; path=/; HttpOnly
Strict-Transport-Security:max-age=63072000; includeSubdomains
X-Clockwork-Id:1510134703.8433.320819117
X-Clockwork-Version:1.14.5
X-Content-Type-Options:nosniff
X-Frame-Options:DENY

Remote response:

General:

Request URL:http://app.remote.net/refreshtokens
Request Method:GET
Status Code:200 OK
Remote Address:xx.xx.xx.81:80
Referrer Policy:no-referrer-when-downgrade

Response headers:

Cache-Control:no-cache, private
Connection:Keep-Alive
Content-Length:56
Content-Type:application/json
Date:Wed, 08 Nov 2017 09:50:39 GMT
Keep-Alive:timeout=5, max=99
Server:Apache/2.4.18 (Ubuntu)
Set-Cookie:XSRF-TOKEN=eyJpdiI6Im9CcFR4SWo0cUdVMzA4Q1pQZFVOM3c9PSIsInZhbHVlIjoiOTNGSndJbTJMaktBcjNIXC8rZWhsYVRHWHIxRmFNRlJwYmxEUW5PWDZQdzZ1eEdMSDQwQldjcFRPT2lWVTJLNUtEdjBwWXNyWmFFamtNS2VoK3VQVlwvUT09IiwibWFjIjoiZTRkZjE1ZGRmMDBhZGFjMjdmOWQ3NjlhMDc4YTEwODg5MmRmZmNlYzIwY2U5ZjNjMjE0NWJlZDgxNTJiNWU3YiJ9; expires=Wed, 08-Nov-2017 11:50:39 GMT; Max-Age=7200; path=/
Set-Cookie:laravel_session=eyJpdiI6Ink4V1N0YUtsK3lFSzFQNnhqa2VLMlE9PSIsInZhbHVlIjoiOVZzYU1xMnNWREJ3amw3Y2ZKK2hsNXV5TW5SNjRvdnZySWZrcHFYYmhXam5oWWZKV3BSNFg4RXNBaWZFWldwRkF0clRIckF6UTRtcGZNTjdSY2x2a2c9PSIsIm1hYyI6IjM0NGNkOWQ0NjM1MTE2M2Q0YTY0MjkwODc4YjM1ZGQ2YjY3NTFlZWJiMmU0NjU0ODgzNTkwMDQwYTI1YmY1ZDQifQ%3D%3D; expires=Wed, 08-Nov-2017 11:50:39 GMT; Max-Age=7200; path=/; HttpOnly

I'm baffled by this. On both servers (and even a third one exhibiting the same behavior as the remote server) the code base is exactly the same. What could cause this discrepant behavior? Some hunches:

awoyele commented 7 years ago

CreateFreshApiToken don't create the laravel_token cookie #400 CreateFreshApiToken not always setting 'laravel_token' #564

laravel_token is literally like a implicit grant oauth route.

  1. You(the client) redirect the consumer to login on resource server.
  2. consumer authorizes you(the client) to get their information.
  3. resource server redirects consumer to your fallback route.
  4. You log the user in or create a new user.
  5. You create an access token the user will use to make requests.

Laravel makes this easier for 1st party app use:

  1. Skip step 1 to 3
  2. Log user in (webview or browser)
  3. You create an access token the user will use to make requests
  4. laravel_token cookie is already set

if you're wondering how to make step 5 to your auth apps... Socialite et al after creating user and login them in use: Auth::user()->createToken('')->accessToken;

give me a smiley if you find this useful :)


Laravel tokens last for a year... Every Login will make a token that expires in a year... If you're not a native or progressive app schedule a job that will clear tokens OR in your AuthServiceProvider add:

Passport::tokensExpireIn(Carbon::now()->addDays(15));
Passport::refreshTokensExpireIn(Carbon::now()->addDays(30));

Read more about this here Don't forget about that smiley :)

dschreij commented 7 years ago

Thanks @awoyele, but you can't pass the laraven_token to the client explicitly by just adding Auth::user()->createToken('')->accessToken; to the JSON response. laravel_token is a httpOnly cookie, so it's shielded completely from JS and is handled/passed by the browser. I could of course se the laravel_token explicitly in my function, but I thought that was the function of the CreateFreshApiToken middleware...

norahw commented 6 years ago

Hi @dschreij, did you manage to resolve this? I am facing the exact same issue and it is driving me nuts!

dschreij commented 6 years ago

I did, partially. When I'm near my dev machine again I'll post my code here.

On Tue, Nov 21, 2017, 18:33 norahw notifications@github.com wrote:

Hi @dschreij https://github.com/dschreij, did you manage to resolve this? I am facing the exact same issue and it is driving me nuts!

— You are receiving this because you were mentioned.

Reply to this email directly, view it on GitHub https://github.com/laravel/passport/issues/564#issuecomment-346102254, or mute the thread https://github.com/notifications/unsubscribe-auth/AAqDlEIaa76JDw0ZQIViuRuvCcUw4xh7ks5s4wligaJpZM4QWKwY .

norahw commented 6 years ago

@dschreij I saw your post on Stackoverflow, is this your solution? https://stackoverflow.com/questions/47032871/refreshing-authentication-tokens-for-a-vue-js-spa-using-laravel-for-the-backend

dschreij commented 6 years ago

It's still not perfect, but I ended up with a login function like:

login(){
    // Copy the user object
    const data = {...this.user};
    // If remember is false, don't send the parameter to the server
    if(data.remember === false){
        delete data.remember;
    }

    this.authenticating = true;

    this.authenticate(data)
        .then( csrf_token => {
            window.Laravel.csrfToken = csrf_token;
            window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrf_token;

            // Perform a dummy GET request to the site root to obtain the larevel_token cookie
            // which is used for authentication. Strangely enough this cookie is not set with the
            // POST request to the login function.
            axios.get('/')
                .then( () => {
                    this.authenticating = false;
                    this.$router.replace(this.$route.query.redirect || '/');
                })
                .catch(e => this.showErrorMessage(e.message));
        })
        .catch( error => {
            // Sometimes the backend is still logged in while the front-end registers as being logged out
            // and weird behavior ensues. In this case, Laravel's guest middleware redirects to the main page
            // and a HTML response is sent instead of JSON. If this happens, send a log out request to the backend
            // logout and refresh if a type error was thrown
            if( error instanceof TypeError){
                console.error('Incorrect response type; possibly redirected');
                this.logout()
                    .then(window.location.reload());
            }

            this.authenticating = false;
            if(error.response && [422, 423].includes(error.response.status) ){
                this.validationErrors = error.response.data.errors;
                this.showErrorMessage(error.response.data.message);
            }else{
                this.showErrorMessage(error.message);  
            }
        });
}

The authenticate function (which is named login in my vuex module) is as follows:

login({ dispatch }, data){
    return new Promise( (resolve, reject) => {
        axios.post(LOGIN, data)
            .then( response => {
                if(response.headers['content-type'] != 'application/json'){
                    return reject(new TypeError('Failed to login; Please try again.'));
                }

                const {csrf_token, ...user} = response.data;
                // Set Vuex state
                dispatch('setUser', user );
                // Store the user data in local storage
                Vue.ls.set('user', user );
                return resolve(csrf_token);
            })
            .catch( error => reject(error) );
    });
}

It still isn't as clean as I'd like it to be, but it seems to largely do the trick. Sometimes Laravel returns a 419 error after not having used the app for a long time (i.e. a day) and behind the screen tries to redirect back to HTML page. I try to catch this throwing a type error and handling it by:

if( error instanceof TypeError){
     console.error('Incorrect response type; possibly redirected');
     this.logout()
        .then(window.location.reload());
}

I perform a page refresh to get everything fresh (e.g. the blade template page and other stuff that is sent with a synchronous request) when this happens.

I also tried directly logging in again after the 419, but that ended in a infinite loop of login attempts:

if( error instanceof TypeError){
     console.error('Incorrect response type; possibly redirected');
     this.logout()
        .then(this.login());
}

So this sort of works for now, but definitely needs some polishing. For now I have lost too much time tackling this problem already, but let me know if you find some improvements.

driesvints commented 6 years ago

Hey @dschreij. I saw you found a solution for your problem on Laracasts? Let me know if this is still a problem for you. It might be best to pick up the convo in #400 as this is sort of a duplicate of that issue. Thanks!

dschreij commented 6 years ago

My solution worked, but showed some weird behavior on the rare occasion, and also felt a bit 'hacky'. Once I started to convert my app to a PWA, it stopped working though, so it's not an ideal solution in any sense. I've also been reading up on this topic a bit since, and I think using a JWT approach is a far better fit to this problem. I started working on other non-laravel projects though, so I haven't had time to tinker around with this myself yet.

driesvints commented 6 years ago

@dschreij I think the problem is Resources specifically because they haven't been converted to responses yet when they pass the CreateFreshApiToken middleware. I've asked for other examples in the other issue so hopefully those will provide more insight.

dschreij commented 6 years ago

Alright, that clarifies it a bit. But does this also explain why it doesn't work with service workers/PWA?

And is the approach that causes this problem a good one in general? The consensus appears to be using JWT for authentication if you are creating a single page app. If that is the case, it may be not worth your time to work on this any longer (not my call, but just my two cents)

driesvints commented 6 years ago

Yeah I believe so as well. I still want to check if we might be able to make the CreateFreshApiToken work better with resource responses though. Just gonna wait what others have to say in the other issue.