flavors / django-graphql-jwt

JSON Web Token (JWT) authentication for Graphene Django
https://django-graphql-jwt.domake.io
MIT License
819 stars 171 forks source link

Refresh Token with HttpOnly cookie #191

Open PyDevX opened 4 years ago

PyDevX commented 4 years ago

I used cookie based auth when i login successfully browser create two cookies : 1 for auth other is refresh due to HttpOnly , I cant access refresh token cookie from js and because of that i cant run refreshToken mutation with argument ? is there missing or wrong something that i miss?

cutamar commented 4 years ago

You need the latest version, 0.3.1. https://github.com/flavors/django-graphql-jwt/pull/165 this includes checking for the refresh token in the cookie, so you don't need to send it.

PyDevX commented 4 years ago

i solve the problem , You are right when i use refreshToken mutation like that it works mutation { refreshToken { token } }

but the actual problem is when set JWT_COOKIE_NAME and JWT_REFRESH_TOKEN_COOKIE_NAME settings other than default value mutation dont work.

Thanks

PyDevX commented 4 years ago

for now JWT_REUSE_REFRESH_TOKENS : True setting not work for JWT-refresh-token cookie it renews whenever token refreshed

merodrem commented 4 years ago

Hello, I have the same question as @PyDevX and I'm not sure to understand how 0.3.1 solves this. I'm storing the token in cookies using the jwt_cookies() decorator. Using the settings from the doc for refresh tokens:

GRAPHQL_JWT = {
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_LONG_RUNNING_REFRESH_TOKEN': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=5),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
}

After 5 minutes the JWT expires and I cannot call the refresh mutation due to httpOnly cookie. Do I understand that django-graphql-jwt takes care of refreshing the token in the cookie the same way it takes care of adding the token in the cookie? In anycase after waiting JWT_EXPIRATION_DELTA, I have to authenticate again. So, how do you refresh the JWT in the cookie?

PyDevX commented 4 years ago

when you want to refresh token just call this mutation with no argument

mutation { refreshToken { token } } then server side assign new cookies both for token and refrestoken But my problem is now : despite i set 'JWT_REUSE_REFRESH_TOKENS : True' setting still it gives new refreshToken

ianrodrigues commented 4 years ago

@PyDevX I understand that I can call mutation { refreshToken { token } } without sending the token at any time I want to refresh the current token. But I'm with @merodrem, how about I want to refresh the token and the expiration time has passed?

PyDevX commented 4 years ago

yet it is true if your refreshToken is expired you wont be able to refresh token because it have been deleted when expired. if your token is expired and your refrehToken cookie is still in your browser just send mutation. it bring new token to you
My problem is when i call refreshToken mutation , although 'JWT_REUSE_REFRESH_TOKENS : True' , it refresh refreshToken too..

kendallroth commented 4 years ago

I'm having absolutely no luck on this as well, and would appreciate some input. From what I can tell in the docs (but especially this issue), calling the authToken mutation will get a JWT and expiry token, and add them to the cookies. Any future calls to refreshToken (without args) should use the same cookie. In practice, not so much...I'm probably misunderstanding something, but the docs seem ambigous here (no clear examples)?

When I call the authToken mutation (either in playground or app), the cookies are present in the response. However, immediate calls to refreshToken (wihout input) do not have the same cookies, and result in the error Refresh token is required. This was because the app and API were running on different ports (and different Docker containers. By proxying the app (via webpack) through the API itself I was able to remove this issue. Additionally, I no longer needed CORS or CSRF exemption.

It should also be noted that the Relay refreshToken mutation requires input, meaning it cannot be used in this way (not that it works for me anyway)...

GRAPHQL_JWT = {
    "JWT_VERIFY": True,
    "JWT_VERIFY_EXPIRATION": True,
    "JWT_ALLOW_REFRESH": True,
    "JWT_LONG_RUNNING_REFRESH_TOKEN": True,
}
urlpatterns = [
    # Removed need for CSRF exemption by proxying app through API (with webpack and Docker network)
    # path("graphql/", csrf_exempt(jwt_cookie(GraphQLView.as_view)))),
    path("graphql/", jwt_cookie(GraphQLView.as_view))),
]
    auth_token = graphql_jwt.ObtainJSONWebToken.Field()
    verify_token = graphql_jwt.Verify.Field()
    refresh_token = graphql_jwt.Refresh.Field()
    # Long running refresh tokens
    revoke_token = graphql_jwt.Revoke.Field()
    delete_refresh_token_cookie = graphql_jwt.refresh_token.DeleteRefreshTokenCookie.Field()

I've been reading along with this guide (Hasura.io - best-practices-of-using-jwt-with-graphql) and it seems to agree on the use of JWTs (using cookies for refresh tokens, etc). However, I have been completely out of luck when trying to translate this into this package.

My thought process is that the user would login (with authToken), which would return the JWT and attach the refresh token as a cookie. When the app loads again, it would first check for a refresh token (how, since it is httpOnly?) or at least request a refreshed token using refreshToken. However, since the cookie isn't present on that request it always returns that error (stating that token is required).

Any ideas?

kendallroth commented 4 years ago

Doing a bit more experimentation with this and wonder if it is because I am using two separate "domains" in development: port 3000 for the app and port 5000 for the api? I tried experimenting the the JWT Cookie Domain setting, but to no avail either.

UPDATE: I have negated this by proxying app requests (via Vue webpack proxy) to the API itself, via the Docker Compose network and container name. I have disabled CORS entirely and verified that I can access the API from the app still, so this shouldn't be the issue any more.

UPDATE 2: A bit more tweaking has shown that authenticating properly sets the cookies; however, I'm still trying to figure a few unexpected behaviours out.

kendallroth commented 4 years ago

I was able to get this working by fixing a few issues, but still think that the docs could be clarified to show an expected workflow for (especially the empty refreshToken mutation).

The basic workflow involves a series of mutations at different points when authentication comes into play. When the app is loaded (ie. after refresh), the Viewer query is the only query run initially (routes are disabled until loading is finished). If it fails, the refresh query kicks in (from Apollo error link). If that also fails, the app redirects to the sign in page (with a redirect url back for after authentication). If the refresh query succeeds, the Viewer query is retried (via same Apollo error link) and the rest of the app is permitted to load and send its own queries. I also add the viewer to Vuex in the Viewer query result callback, which lets the app know that the user is authenticated (for UI purposes).

Next steps would be immediately refreshing token when the app is refreshed (to get time to its expiry), then automatically refreshing before it would expire (to keep Apollo link from having to do so and slow an operation down).

austincollinpena commented 4 years ago

@kendallroth Thanks for your great write ups.

Do you know how I can get the cookie to set in development when I'm using two different domains? I've added 'localhost' to Allowed hosts and set up CORs to allow 'localhost' as well.

However, even when my Response object has the setCookie attribute it doesn't set on localhost:3000 where my Next App is running.

kendallroth commented 4 years ago

I will confess that I am not overly familiar with either CORS or cookies, but can share what I remember. The important part that I remember is that, in order for HTTP only cookies to work, the app and server must be on the same domain. In local development, this means the same port as well (from what I can tell). Therefore, in development is it necessary to proxy the app's requests through the server itself somehow (example). So I'm not sure that two different domains would work with cookies unfortunately. In production, the app will typically be served from the same domain as the API (at least, that's how I will do it). Therefore, cookies will work by default (again, my understanding). However, should you use another domain (for example, serve the app with a CDN), I'm not sure how that would work (since it is two separate domains).

TL;DR: The app and server appear to need to be on the same domain in order for HTTP Only cookies to work.

Here's a link to my repository, see if following the code helps at all 😏 (GitLab - kendallroth/ourgroup)

P.S. I welcome corrections from those more knowledgeable than myself; I am only speaking from my recent experience.

kendallroth commented 4 years ago

I have finally gotten to the stage of readying the app for production, and immediately ran headfirst into this issue. I am trying to serve the app from one subdomain and the API on another (same TLD). However, the JWT cookie (included in request after authentication) is not persisted with the other requests (it's broken...). I tried experimenting with JWT_COOKIE_DOMAIN by setting it to the app subdomain, but no dice.

Is it not common to use JWT tokens via cookies on different domains, or does everyone serve the app from the same domain as the API?

I would appreciate guidance and documentation on this topic, as the limited resources right now do not really indicate how these cookies should be used (I understand that they are relatively new).

P.S. I have also had to re-enable CORS due to the different domains, and possible CSRF (appears to work so far?).

kendallroth commented 4 years ago

@mongkok Would you be able to lend some insight into how the JWT_COOKIE_DOMAIN (and possibly JWT_COOKIE_SAMESITE) are intended to be used? Do they need to be used together to allow cross domain cookies? Should setting JWT_COOKIE_DOMAIN to the app's domain be enough to enable cross domain cookies between an app and server on different subdomains (but same domain)?

I can provide examples for the documentation if I get this working, but at the moment I am unfortunately stuck.

psdon commented 4 years ago

Any news about this? @kendallroth

kendallroth commented 4 years ago

No @psdon, I'm still hoping for some help or insight from the maintainers. It seems like there is support for the use case, but a lack of knowledge on my part or documentation here.

psdon commented 4 years ago

What's your current setup in production? You still create a proxy between the GraphQL API and your front-end?

kendallroth commented 4 years ago

I use Dokku on Digital Ocean to manage my app and server as two different "containers." The Python API is deployed relatively easily and run with gunicorn, while the app is first build then served with a simple node server script. The app and server containers use different subdomains (groups. and groups-api. respectively) of the same root domain.

I only proxy between the API and my app in local development (not production currently), as I understand that the different ports on localhost are treated as different hosts entirely?

// vue.config.js (Webpack override)
devServer: {
  // Webpack proxy is used in devlopment to eliminate CORS by proxying requests
  //   through the API itself (using Docker network).
  proxy: {
    "^/graphql/": {
      target: "http://groups-api:5000/",
      changeOrigin: true,
    },
  },
}

When I try to log in on production, the request succeeds but and the cookie is set on the response. However, the cookie is not present on the next request/response, so it appears to be stripped off by the server.

# app/settings.py
DJANGO_ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")
DJANGO_CORS_WHITELIST = os.getenv("DJANGO_CORS_WHITELIST", "").split(",")

JWT_APP_DOMAIN = os.getenv("JWT_APP_DOMAIN", None)

INSTALLED_APPS = [
    # ...django apps
    "graphene_django",
    "graphql_jwt.refresh_token.apps.RefreshTokenConfig",
    # NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied through
    #         a webpack proxy to the API itself!
    "corsheaders",
    "graphql_playground",
    # ...my apps
]

MIDDLEWARE = [
    # ...django middleware
    "django.contrib.sessions.middleware.SessionMiddleware",
    # NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied through
    #         a webpack proxy to the API itself!
    # Apparently needs to be placed high in the list (above "CommonMiddleware")
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    # Must be declared to allow non-graphql authentication
    # 'graphql_jwt.middleware.JSONWebTokenMiddleware',
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

GRAPHENE = {
    "SCHEMA": "app.schema.schema",
    "MIDDLEWARE": ("graphql_jwt.middleware.JSONWebTokenMiddleware",),
}

ALLOWED_HOSTS = [
    "localhost",
    # Allow Docker containers IN DEVELOPMENT
    "groups-api",
    "groups-app",
] + DJANGO_ALLOWED_HOSTS

AUTHENTICATION_BACKENDS = [
    "graphql_jwt.backends.JSONWebTokenBackend",
    # Custom authentication backend is necessary to properly give errors in "tokenAuth"
    #   mutation, and custom sign in mutation cannot return refresh tokens...
    "util.auth.GraphQLAuthBackend",
]

GRAPHQL_JWT = {
    ...
    "JWT_COOKIE_DOMAIN": JWT_APP_DOMAIN,
    # JWT token workflow
    "JWT_VERIFY": True,
    "JWT_EXPIRATION_DELTA": timedelta(minutes=15),
    "JWT_VERIFY_EXPIRATION": True,
    ...
}

# NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied
#         through a webpack proxy to the API itself!
CORS_ORIGIN_WHITELIST = DJANGO_CORS_WHITELIST
CORS_ALLOW_CREDENTIALS = True

Here's the repository if it gives any more insight into what may be happening (tried to summarize settings.py above).

I am wondering if it is a misunderstanding of something on my part, as I thought that cookies could be passed across subdomains of the same root domain?

psdon commented 4 years ago

Why not just proxy the API and the front-end in the same domain? Using Nginx

On Sat, Sep 5, 2020, 11:49 PM Kendall Roth notifications@github.com wrote:

I use Dokku on Digital Ocean to manage my app and server as two different "containers." The Python API is deployed relatively easily and run with gunicorn, while the app is first build then served with a simple node server script. The app and server containers use different subdomains ( groups. and groups-api. respectively) of the same root domain.

I only proxy between the API and my app in local development (not production currently), as I understand that the different ports on localhost are treated as different hosts entirely?

// vue.config.js (Webpack override) devServer: { // Webpack proxy is used in devlopment to eliminate CORS by proxying requests // through the API itself (using Docker network). proxy: { "^/graphql/": { target: "http://groups-api:5000/", changeOrigin: true, }, },}

When I try to log in on production, the request succeeds but and the cookie is set on the response. However, the cookie is not present on the next request/response, so it appears to be stripped off by the server.

app/settings.pyDJANGO_ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",")DJANGO_CORS_WHITELIST = os.getenv("DJANGO_CORS_WHITELIST", "").split(",")

JWT_APP_DOMAIN = os.getenv("JWT_APP_DOMAIN", None) INSTALLED_APPS = [

...django apps

"graphene_django",
"graphql_jwt.refresh_token.apps.RefreshTokenConfig",
# NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied through
#         a webpack proxy to the API itself!
"corsheaders",
"graphql_playground",
# ...my apps

] MIDDLEWARE = [

...django middleware

"django.contrib.sessions.middleware.SessionMiddleware",
# NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied through
#         a webpack proxy to the API itself!
# Apparently needs to be placed high in the list (above "CommonMiddleware")
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
# "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
# Must be declared to allow non-graphql authentication
# 'graphql_jwt.middleware.JSONWebTokenMiddleware',
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",

] GRAPHENE = { "SCHEMA": "app.schema.schema", "MIDDLEWARE": ("graphql_jwt.middleware.JSONWebTokenMiddleware",), } ALLOWED_HOSTS = [ "localhost",

Allow Docker containers IN DEVELOPMENT

"groups-api",
"groups-app",

] + DJANGO_ALLOWED_HOSTS AUTHENTICATION_BACKENDS = [ "graphql_jwt.backends.JSONWebTokenBackend",

Custom authentication backend is necessary to properly give errors in "tokenAuth"

#   mutation, and custom sign in mutation cannot return refresh tokens...
"util.auth.GraphQLAuthBackend",

] GRAPHQL_JWT = { ... "JWT_COOKIE_DOMAIN": JWT_APP_DOMAIN,

JWT token workflow

"JWT_VERIFY": True,
"JWT_EXPIRATION_DELTA": timedelta(minutes=15),
"JWT_VERIFY_EXPIRATION": True,
...

}

NOTE: CORS is currently not necessary IN DEVELOPMENT since app requests are proxied# through a webpack proxy to the API itself!CORS_ORIGIN_WHITELIST = DJANGO_CORS_WHITELISTCORS_ALLOW_CREDENTIALS = True

Here's the repository https://gitlab.com/kendallroth/ourgroup/-/blob/8-deploy-with-heroku/api/app/settings.py if it gives any more insight into what may be happening (tried to summarize settings.py above).

I am wondering if it is a misunderstanding of something on my part, as I thought that cookies could be passed across subdomains of the same root domain?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/flavors/django-graphql-jwt/issues/191#issuecomment-687628137, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHPURLAW2OMJFESQKBTGW7LSEJMZ5ANCNFSM4MGPKIIA .

kendallroth commented 4 years ago

That's a good question, with a two-part answer 😄. The first part is that I have never used Nginx for a proxy like this and am not quite sure where to start (time to bring out my Google-fu)... The second part is that I was assuming it would not be necessary given the JWT_COOKIE_DOMAIN (and possibly JWT_COOKIE_SAMESITE) configuration settings (and my semi-knowledge of cookies on subdomains).

chidimo commented 3 years ago

So I'm using apollo client for my frontend and graphene for the backend. Here are a few of the configurations I had to do to get cookie-based authentication working. I'm also using django-cors-headers

apolloclient

const httpLink = createHttpLink({
  uri: 'some-url-string',
  credentials: 'include' // this is a must for cross-domain requests.
});

url.py

path('graphql/', jwt_cookie(csrf_exempt(GraphQLView.as_view(graphiql=True)))),

settings.py

CORS_ALLOW_CREDENTIALS = True # this must be included

In case anyone else runs into this. It took me many hours to understand what was really going on.

SCENARIO 1

So if you use this configuration

GRAPHQL_JWT = {
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=5),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
}

You will have to refresh the token every 5 mins. In this case, you have to pass the token yourself to the refreshToken query. But after 7 days any attempt to refresh it will fail. Which means you need to get a new one.

export const REFRESH_TOKEN = gql`
  mutation RefreshToken($token: String!) {
    refreshToken(token: $token) {
      token
      payload
      refreshExpiresIn
    }
  }
`;

Also, you will not be able to query for the refreshToken field in the token_auth mutation.

SCENARIO 2

If you instead opt for a long running refresh token by including 'JWT_LONG_RUNNING_REFRESH_TOKEN': True, in your GRAPHQL_JWT settings, you will have to include this additional line in your INSTALLED_APPS and run python manage.py migrate to create the database entries.

INSTALLED_APPS = [
    ...
    'graphql_jwt.refresh_token.apps.RefreshTokenConfig',
    ...
]

This will also have the effect of setting the JWT-refresh-token cookie for you. This means that you can call the refreshToken mutation without passing the token

export const REFRESH_SILENTLY = gql`
  mutation RefreshSilently {
    refreshToken {
      token
      payload
      refreshExpiresIn
    }
  }
`;

In this case, you can keep refreshing the refresh token (i.e replacing the old with the new) for as long as you want even though it has expired after 7 days. You then need to find a way to expire the token yourself and assign the user a new one. To automate this process, you could just run python manage.py cleartokens --expired on a daily basis to remove expired tokens.

I'm open to correction on my current understanding.

Also in both cases you don't need to pass the authorization header since the JWT cookie is already set.

ptrhck commented 3 years ago

@chidimo I am currently trying to get the same setup going. Maybe this is a more general question, but if I use GraphiQL, should I see the Cookie also in Chrome dev tools set under Applicatoin-->Cookies? As mentioned in https://github.com/flavors/django-graphql-jwt/issues/210, I can also only see it in the setcookie response.

My settings look like this:

CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True

GRAPHQL_JWT = {
    'JWT_COOKIE_SECURE': False if ENVIRONMENT == "dev" else True,
    'JWT_COOKIE_DOMAIN': 'api.company.local',
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=15),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7)
}
ivarsg commented 3 years ago

@ptrhck first of all you, should definitely see set-cookie response headers when tokens are issued. Then, if cookie domain, path and other attributes are correct (match), you will see the cookies in Applicatoin-->Cookies of Chrome devtools.

ptrhck commented 3 years ago

@ivarsg Thank you! So this also holds true for GraphiQL?

On the other hand, asumming that the GraphQL API is at domain api.staging.company.com and the frontend with Apolllo client is at app.staging.company.com, do I need any domain settings or any other settings then the following?

CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True

GRAPHQL_JWT = {
    'JWT_COOKIE_SECURE': False if ENVIRONMENT == "dev" else True,
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=15),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7)
}
syberen commented 3 years ago

@ptrhck My setup works with the same settings as yours, on different subdomains.

In my case I had trouble getting it to work, but it turned out to be a setting in cloudfront which blocked cookies (origin request policy had to be set to Managed-AllViewer).

This is most likely not relevant to your situation, but posting it here anyway on the off chance it helps someone.

ptrhck commented 3 years ago

@syberen Thank you so much for your hint. It was an Nginx problem locally and an AWS problem in the cloud.

ptrhck commented 3 years ago

@kendallroth

Could you share your implementation how you have solved the following steps in Apollo?

kendallroth commented 3 years ago

@ptrhck While I never really resumed the project after setting it aside while seeking answers (due to project needs shifting), I think I have the answers you may be looking for. I've attached it here with some documentation I left, as it wasn't fully implemented (but I believe it was working to some extent)?

Call refreshToken whenever a request fails because of authentication

// Error link for handling GraphQL errors
const errorLink = onError(
  ({ forward, graphQLErrors, networkError, operation }) => {
    // if (response.errors && response.errors.length > 0) {
    // TODO: Possibly re-enable this in the future to force mutation callback errors.
    //         Currently, Apollo treats anything with non-null 'data' as a success.
    //         However, data is set to an object even if an error occurred.
    //  response.data = null;
    // }

    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        if (err && err.message) {
          switch (err.message) {
            // Handle missing refresh tokens (when trying to refresh a JWT)
            case "Refresh token is required":
              break;
            // Retry queries/mutations after attempting to refetch a valid JWT
            case "NOT_AUTHORIZED":
              // TODO: After refreshing start countdown to next refresh (based on expiry)

              // Taken from a variety of sources:
              //   - https://www.apollographql.com/docs/link/links/error/
              //   - https://github.com/apollographql/apollo-link/issues/646#issuecomment-423279220
              return promiseToObservable(Auth.refresh()).flatMap(() => forward(operation));
          }
        }
      }
    }
  ...
  }
);

Calling revokeToken, deleteTokenCookie, and deleteRefreshTokenCookie when logging out

  /**
   * Signout the user
   *
   * Since JWT tokens (stored in cookies) are used for authentication,
   *   it is necessary to remove the JWT and refresh tokens.
   *
   * @param {function} cb - Optional callback
   */
  static async signout(cb = null) {
    Store.commit(`auth/${AUTH__AUTH_REMOVE}`);
    Store.commit(`auth/${AUTH__REFRESH_TIMEOUT_REMOVE}`);

    // NOTE: Operations are performed separately and in order of importance, in case one of them somehow fails.

    // Revoke the refresh token
    await Apollo.mutate({ mutation: RevokeTokenMutation });
    // Delete the JWT cookie and refresh token
    await Apollo.mutate({ mutation: DeleteRefreshTokenCookieMutation });
    await Apollo.mutate({ mutation: DeleteTokenCookieMutation });

    // Optional callback (router redirection, etc)
    cb && cb();
  }

Bonus points: auto-refreshing the JWT every so often

  /**
   * Refresh the current JWT (and trigger automatic refresh)
   */
  static async refresh() {
    const response = await Apollo.mutate({ mutation: RefreshTokenMutation });

    if (!config.app.isProduction) console.info("JWT token was refreshed");

    const expiryTime = response.data.refreshToken.payload.exp;
    const currentTime = Date.now().valueOf() / 1000;
    const timeToExpiry = Math.floor(expiryTime - currentTime);

    // Set a timeout to automatically refresh the token before this one expires
    Store.dispatch(`auth/${AUTH__REFRESH_TIMEOUT_SET}`, timeToExpiry);
  }

Let me know if this is of any help

weilu commented 3 years ago

Is there a way to do logout in one mutation query rather than 3? It seems a bit excessive having to make 3 mutation calls (Revoke, DeleteJSONWebTokenCookie, DeleteRefreshTokenCookie) just to logout properly =/

JayOneTheSk8 commented 3 years ago

So I think I've figured out how to pass the cookie through Apollo using different ports (I'm using http://localhost:3000 for React with Apollo and http://localhost:8000 fro graphene-django). I have only been working on this a few months/weeks really so excuse my lack of full in-depth knowledge. I'm only a student at this but I've been knocking my head against the wall for a while with this.

The first thing I did was read like every single page of the documentation related to auth cookies and found out that there were a lot more features in django_graphql_jwt 0.3.1 so I ad to figure out how to install it and here's the relevant packages in myPipfile:

graphene-django = "*"
django-cors-headers = "*"
pyjwt = "==1.7.0"
django-graphql-jwt = "==0.3.1"

So I played around with some of the new settings after looking at this page in the docs https://django-graphql-jwt.domake.io/en/latest/settings.html#cookie-authentication

And here is the relevant stuff in my settings.py:

from datetime import timedelta

INSTALLED_APPS = [
    'corsheaders',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'graphene_django', # graphene
    'items',
    'profiles',
    'rest_framework', # nevermind this, I'm not using it atm
    'graphql_jwt.refresh_token.apps.RefreshTokenConfig' # refresh config
]

# Like I said I'm not using it I just copy pasted this
REST_FRAMEWORK = {
    # Use Django's standard `django.contrib.auth` permissions,
    # or allow read-only access for unauthenticated users.
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
    ]
}

# my schema
GRAPHENE = {
    'SCHEMA': 'myapp.schema.schema',
    'MIDDLEWARE': [
        'graphql_jwt.middleware.JSONWebTokenMiddleware'
    ]
}

GRAPHQL_JWT = {
    'JWT_VERIFY_EXPIRATION': True,
    'JWT_LONG_RUNNING_REFRESH_TOKEN': True,
    'JWT_EXPIRATION_DELTA': timedelta(minutes=5),
    'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=7),
}

AUTH_USER_MODEL = 'profiles.Profile' # Have my own personal user type

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware', # put cors on top of course
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware', 
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware'
]

CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True # I think this is what allows it
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000" # I literally just wasn't sure if localhost was enough
]

AUTHENTICATION_BACKENDS = [
    'graphql_jwt.backends.JSONWebTokenBackend',
    'django.contrib.auth.backends.ModelBackend',
    'myapp.backends.EmailBackend' # a backend I have to allow login via email
]

Now I just need to add the jwt_cookie wrapper in the urls.py

from django.contrib import admin
from django.urls import path
from graphene_django.views import GraphQLView
from django.views.decorators.csrf import csrf_exempt
from graphql_jwt.decorators import jwt_cookie

urlpatterns = [
    path('admin/', admin.site.urls),
    path('graphql/', jwt_cookie(csrf_exempt(GraphQLView.as_view(graphiql=True))))
]

And verify on localhost:8000 I get the cookies on tokenAuth (I can't upload a pic)

So now I put this in my Apollo settings:

// This is just to query a variable I made in the client I'm notgoing to be using it later
const typeDefs = gql`
  extend type Query {
    isLoggedIn: Boolean!
  }
`;

// Just print out the errors for now
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message }) => {
      console.log(message);
    });
  }
  if (networkError) {
    console.log(networkError.message)
  }
});

// make your link
const link = from([
  errorLink,
  new HttpLink({ 
    uri: 'http://localhost:8000/graphql/', 
    credentials: 'include' // include credentials here
  })
]);

const client = new ApolloClient({
  link,
  cache,
  typeDefs,
});

And when I login like this

    export const LOGIN_USER = gql`
        mutation($username: String!, $password: String!) {
            tokenAuth(username: $username, password: $password) {
                payload
                refreshExpiresIn
            }
        }
    `;

    const [loginUser, { loading }] = useMutation(LOGIN_USER, {
        onError(err) {
            err.graphQLErrors.forEach((errorObj) => {
                if (errorObj.message.includes(INVALID_CREDENTIALS_ERROR)) {
                    setErrors({ ...errors, [INVALID_CREDS]: INVALID_CREDENTIALS_ERROR }); // setting custom errors
                }
            });
        }
    });

    const authenticateUser = (e) => {
        e.preventDefault();
        loginUser({
            variables: {
                username: username.toLowerCase().trim() || email.toLowerCase().trim(),
                password
            }
        }).then((res) => {
            if (res.data) {
                const { refreshExpiresIn, payload: { exp } } = res.data.tokenAuth; set only the expiry times in localStorage
                context.login({ refreshExpiresIn, exp });
            }
        });
    };

Hope this helps anyone because I was stuck for a WHIIIIIILE

starascendin commented 2 years ago

I still have issues with this.

On my local, w/ django running in localhost:8000 and my frontend running at localhost:4000, i was able to hit TokenAuth and cookies are peristed.

However, on my dev env, django is in a subdomain django.mydomain.com and frontend in another subdomain frontend.mydomain.com and frontend does not persist the cookies.

Anyone know how to address this issue? i have no clue why the cookies are not being saved on frontend in my prod env but does in my local.

Peter-Paul commented 2 years ago

I used cookie based auth when i login successfully browser create two cookies : 1 for auth other is refresh due to HttpOnly , I cant access refresh token cookie from js and because of that i cant run refreshToken mutation with argument ? is there missing or wrong something that i miss?

You could also create a child class e.g MyTokenViewBase from parent TokenViewBase and override the post method to access the cookie from request.headers['Cookie'] instead of request.data . Then modify the TokenRefreshView to inherit from the child class, MyTokenViewBase , and finally pass it into api/token/refresh path