Typescript client - hook into bearer token refresh

I have a need to send along the bearer token as a query string ( ss-tok ) sometimes in the url (in particular for file upload/download) - so I am able to stash the bearer token doing a post to auth, but after expiry, the client is automatically using the refresh token which kind of works but causes two things to happen:

  1. a lot of redundant calls to refresh the bearer token as we reset the bearer token from localstorage when they load the app

  2. Me having to make a competing call to get a new bearer token for use in non-ts client api calls (which might also exacerbates 1)

Any thoughts on a different approach to share the refreshed bearer token? I don’t see any suitable event hooks but I might have missed something.

1 Like

Well the recommendation would instead be to use the default JWT Token Cookies so bearer and refresh tokens are never stored in localStorage and are instead maintained in HTTP only cookies which last as long as the JWT Token and should transparently refresh when they’re expired.

Given it sounds like these issues are due to your manual book keeping of tokens, I’d recommend avoiding it by using JWT Cookies which uses the existing Browser Cookies for its authenticated requests.

I’ve never had much luck with the default behavior and I’m sure its something we’ve got configured wrong but here’s what’s happening:

JWT Server Config:

 new JwtAuthProvider() {

                        ExpireTokensIn        = TimeSpan.FromDays(30),  // JWT Token Expiry
                        ExpireRefreshTokensIn = TimeSpan.FromDays(365), // Refresh Token Expiry
                        

                           UseTokenCookie = true, AllowInQueryString = true, AuthKeyBase64 = Environment.GetEnvironmentVariable("JWT_KEY")},
  

Login Method on the client (which gets a 200 and looks good)

 login(email:string,password:string) {


    let request = new Authenticate();
    request.provider = "credentials";
    request.userName = email;
    request.password = password;
    request.rememberMe = true;

    return this.client.post(request).then((r) => {
        localStorage.setItem("JsonServiceClient_roles",JSON.stringify(r.roles));   
    });

The next request looks like this:

  this.client =  new JsonServiceClient(this.apiUrl());
        this.client.useTokenCookie = true;
       ...
      getJobHistory() {


    const request = new someDto();
    request.skip = 0;
    request.take = 10;

    return this.client.get(request);


  }

![image|634x500](upload://1IUq89Nwa4fVH5hoJk3JBe4DscS.png)

And this is what happens:

no cookies sent

despite cookies:

UseTokenCookie is now the default, so you don’t need to specify it on the JwtAuthProvider.

The HTTP Only secure ss-tok and ss-reftok cookies are not accessible from JavaScript so you wont be able to store it in localStorage since (and why they’re recommended for JWTs).

You don’t need to touch the JsonServiceClient (i.e. set useTokenCookie) since it uses the browser’s cookies, you should be able to authenticate and immediately make authenticated requests with the same client immediately, e.g:

const client = new JsonServiceClient(this.apiUrl())

const authResponse = await client.post(new Authenticate({
  provider:'credentials',
  userName,
  password
}))

const api = await client.api(new MyRequest({ skip:0, take: 10 }))
if (api.succeeded) {
  console.log(api.response)
}

Hmm have stripped it down and cookies are being received, visible in the chrome debug console and response to authenticate, but not being sent on subsequent api call which is then getting a 401. I am using latest SS server and the client npm package is “@servicestack/client”: “^1.2.1”, Maybe something with cors?

   Plugins.Add(new CorsFeature(
                    allowOriginWhitelist: new[] { "http://localhost:3000",  },

            allowedMethods: "GET, POST, PUT, DELETE, OPTIONS,PATCH",
        allowedHeaders: "Content-Type,enctype,filename,Authorization",
                                            allowCredentials: true));

 Plugins.Add(
                          //Add all the Auth Providers you want to allow registration with
                          new AuthFeature(
            () => new AuthUserSession(),
new IAuthProvider[] {
                  
                        new CredentialsAuthProvider(),
                        //new ApiKeyAuthProvider() { InitSchema = true, RequireSecureConnection = sslReq,SessionCacheDuration = TimeSpan.FromMinutes(1440),},
                        new JwtAuthProvider() {

                        //ExpireTokensIn        = TimeSpan.FromDays(30),  // JWT Token Expiry
                        //ExpireRefreshTokensIn = TimeSpan.FromDays(365), // Refresh Token Expiry
                        

                          // UseTokenCookie = true,
                           // AllowInQueryString = true, 
                           AuthKeyBase64 = Environment.GetEnvironmentVariable("JWT_KEY")},
                   //     RequireSecureConnection = false
                   // },
 const client = new JsonServiceClient(this.apiUrl());

    const authResponse = await client.post(new Authenticate({
        provider:'credentials',
        userName,
        password
      }));


   console.log(authResponse);

   localStorage.setItem("JsonServiceClient_roles",JSON.stringify(authResponse.roles)); 
   
   const jobHistResp = await client.post(new getmemberjobHistReq({}));

   console.log(jobHistResp);

Possible, are you running any different domains or ports when testing? Can you see other cookies on the request? If you can share the raw request and response requests including headers both ways, might be something there to indicate why cookies aren’t being sent.

in this case its https://localhost:8443 for the api and http://localhost:3000 for the client.

Raw response headers from Authenticate:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 20 Mar 2024 22:06:36 GMT
Server: Kestrel
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type,enctype,filename,Authorization
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS,PATCH
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Max-Age: 600
Set-Cookie: ss-id=1t0dYbx7cws4vqwBPXs9; path=/; secure; samesite=lax; httponly
Set-Cookie: ss-pid=zcBlgdK3WqbcPIXyaxud; expires=Sun, 20 Mar 2044 22:06:36 GMT; path=/; secure; samesite=lax; httponly
Set-Cookie: ss-opt=temp; expires=Sun, 20 Mar 2044 22:06:36 GMT; path=/; secure; samesite=lax; httponly
Set-Cookie: X-UAId=2019; expires=Sun, 20 Mar 2044 22:06:36 GMT; path=/; secure; samesite=lax; httponly
Set-Cookie: ss-tok=exxxxOTcsImVtYWlsIjoiYWRtaW5AYWRtaW4uY29tIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW5AYWRtaW4uY29tIiwicm9sZXMiOlsiQWRtaW4iLCJNZW1iZXIiXSwianRpIjoxfQ.yjFt_6yOCdSPyO0bdfhHK3MzzYi0UE71_WUI5MxCZgM; expires=Wed, 03 Apr 2024 22:06:37 GMT; path=/; secure; samesite=lax; httponly
Set-Cookie: ss-reftok=xxxxxx; expires=Thu, 20 Mar 2025 22:06:37 GMT; path=/; secure; samesite=lax; httponly
Transfer-Encoding: chunked
Vary: Accept
X-Powered-By: ServiceStack/8.22 NET6/macOS/net6/IN
X-Cookies: ss-tok,ss-reftok

Auth Response:

{
    "userId": "2019",
    "sessionId": "xxxxx",
    "userName": "admin@admin.com",
    "displayName": "",
    "profileUrl": "data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E .path%7B%7D %3C/style%3E%3Cg id='male-svg'%3E%3Cpath fill='%23556080' d='M1 92.84V84.14C1 84.14 2.38 78.81 8.81 77.16C8.81 77.16 19.16 73.37 27.26 69.85C31.46 68.02 32.36 66.93 36.59 65.06C36.59 65.06 37.03 62.9 36.87 61.6H40.18C40.18 61.6 40.93 62.05 40.18 56.94C40.18 56.94 35.63 55.78 35.45 47.66C35.45 47.66 32.41 48.68 32.22 43.76C32.1 40.42 29.52 37.52 33.23 35.12L31.35 30.02C31.35 30.02 28.08 9.51 38.95 12.54C34.36 7.06 64.93 1.59 66.91 18.96C66.91 18.96 68.33 28.35 66.91 34.77C66.91 34.77 71.38 34.25 68.39 42.84C68.39 42.84 66.75 49.01 64.23 47.62C64.23 47.62 64.65 55.43 60.68 56.76C60.68 56.76 60.96 60.92 60.96 61.2L64.74 61.76C64.74 61.76 64.17 65.16 64.84 65.54C64.84 65.54 69.32 68.61 74.66 69.98C84.96 72.62 97.96 77.16 97.96 81.13C97.96 81.13 99 86.42 99 92.85L1 92.84Z'/%3E%3C/g%3E%3C/svg%3E",
    "roles": [
        "Admin",
        "x",
        "x",
        "x",
        "x"
    ],
    "permissions": []
}

Next api call, this is the OPTIONS cors call response headers:

HTTP/1.1 200 OK
Content-Length: 0
Date: Wed, 20 Mar 2024 22:06:39 GMT
Server: Kestrel
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type,enctype,filename,Authorization
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS,PATCH
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Max-Age: 600
Vary: Accept
X-Powered-By: ServiceStack/8.22 NET6/macOS/net6/IN

And the request and response headers for the POST:

POST /json/reply/getmemberjobHistReq HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
Connection: keep-alive
Content-Length: 2
Host: localhost:8443
Origin: http://localhost:3000
Referer: http://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36
content-type: application/json
sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"
sec-ch-ua-mobile: ?1
sec-ch-ua-platform: "Android"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Wed, 20 Mar 2024 22:06:39 GMT
Server: Kestrel
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type,enctype,filename,Authorization
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS,PATCH
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Max-Age: 600
Set-Cookie: ss-pid=xHRbtz7lhPsPty2jt0ap; expires=Sun, 20 Mar 2044 22:06:36 GMT; path=/; secure; samesite=lax; httponly
Set-Cookie: ss-id=dbJO6wp3SHQHRe3qwalV; path=/; secure; samesite=lax; httponly
Vary: Accept
WWW-Authenticate: credentials realm="/auth/credentials"
X-Powered-By: ServiceStack/8.22 NET6/macOS/net6/IN

Perhaps this a clue:

Chrome sez… "A cookie was not sent to a secure origin from an insecure context. Because this cookie would have been sent across schemes on the same site, it was not sent. This behavior enhances the SameSite attribute’s protection of user data from request forgery by network attackers.

Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS. It is also recommended to mark the cookie with the Secure attribute if that is not already the case."

ARGGGGGGGGGGGG

this is the fix:
SetConfig(new HostConfig { UseSameSiteCookies=false,

(for local debug in this case where we go from http to https)

1 Like

Also for posterity’s sake on the original issue, once cookies are working ok, you can do fetch() outside the ts client as long as you set the include credentials param, otherwise cookies not sent along

 return fetch(url, {
                method: 'POST',
                credentials: 'include',
                body: formData
            })
            .then(r => {

Yep, this is also what the JsonServiceClient uses, just fetch with credentials: 'include'.