Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Is there a sane way to store tokens created by a service? #325

Closed
2 of 5 tasks
dkreft opened this issue Jun 24, 2020 · 11 comments
Closed
2 of 5 tasks

Is there a sane way to store tokens created by a service? #325

dkreft opened this issue Jun 24, 2020 · 11 comments
Labels
bug Something isn't working question Ask how to do something or how something works

Comments

@dkreft
Copy link

dkreft commented Jun 24, 2020

Is it possible to use store provided by an existing non-OAuth authentication service?

I'm trying to use Providers.Credentials to integrate with an existing backend services that issues JWTs, but I'm having a heck of a time figuring out how to make this work.

When I POST /auth/tokens with the credentials, the access and refresh tokens are handed back to me. I need to have these tokens available to me in the API layer so that I can add them to the request headers of subsequent requests, but it's not clear to me how to sanely store these for later access (storing them in a global or singleton in a package feels side-effecty and generally "icky").

Here's what I have so far:

async function authorize({ username, password }) {
  const client = makeClient()

  try {
    const {
      data: {
        access,
        refresh,
      },
    } = await client.post('/auth/tokens', {
      email: username,
      password,
    })

    const { data: user } = await client.get('/users/me', {
      headers: {
        Authorization: makeAuthHeader(access),
      }
    })

    return user
  } catch (error) {
    console.error('ERROR: %o', error)
    return null
  }
}

Documentation feedback

  • Found the documentation helpful
  • Found documentation but was incomplete
  • Could not find relevant documentation
  • Found the example project helpful
  • Did not find the example project helpful

I've even dug through the source code a little to see if there was a way to "sneak" these tokens into the session, but I didn't see a clear path forward.

@dkreft dkreft added the question Ask how to do something or how something works label Jun 24, 2020
@iaincollins
Copy link
Member

iaincollins commented Jun 24, 2020

Hey thanks for the detail and for feedback on documentation.

I think I understand what you want to do and it makes sense. I actually had to check to see what would happen when I tried it because it's a reasonable expectation but I wasn't sure it was supported or not.

It turns out there is a bug with the credentials flow and user object isn't persisted when you sign in, but is supposed to be. You should be able to access the user object returned in the jwt() and signin() callbacks, but the user is coming though as a function rather than an object.

^ Doh was wrong, the problem was with the example code in the Documentation not in NextAuth.js itself! It actually works and there is no bug.

@iaincollins iaincollins added the bug Something isn't working label Jun 24, 2020
@dkreft
Copy link
Author

dkreft commented Jun 24, 2020

@iaincollins Thanks, but the problem isn't that I can't get at the user object....the problem is that there's no easy way to store the access and refresh tokens I get back from my server. After much tinkering around, here's what I currently have. As you can see from the code below, I basically had to pass the res object all the way down into my authorize function so that I could set a pair of cookies. If you can think of a better solution, I'm all ears!

export default function handleRequest(req, res) {
  return nextAuth(req, res, makeConfig(req, res))
}

function makeConfig(req, res) {
  const authorize = makeAuthorizeFn(req, res)

  // redacted
}

export function makeAuthorizeFn(res) {
  return async function authorize({ username, password }) {
    const client = makeClient()
    try {
      const {
        data: {
          access,
          refresh,
        },
      } = await client.post('/auth/tokens', {
        email: username,
        password,
      })

      // This is a bit hacky, but at least it gets the tokens back
      // to the client.
      res.setHeader('Set-Cookie', [
        serialize('access', access, { path: '/' }),
        serialize('refresh', refresh, { path: '/' }),
      ])

      //  redacted
}

@iaincollins
Copy link
Member

Oh sure! The idea is that you should be able to set them on the user object and for that to get persisted (securely) in the JWT.

@dkreft
Copy link
Author

dkreft commented Jun 24, 2020 via email

@iaincollins
Copy link
Member

If you add a property to the user object in this scenario, then it's persisted to the JSON Web Token (assuming you use the work around above until the bug is fixed - when it's fixed you won't need to touch the jwt callback).

The session callback can be used to decide what properties can be safely exposed / exported from the JWT to the client side session object, if that makes sense.

@dkreft
Copy link
Author

dkreft commented Jun 24, 2020

@iaincollins, while I did notice that the example provided in the documentation was setting the user as a function, I do not have that problem because I'm already resolving the authorize function with the user object...but you couldn't know that because I redacted the code to keep the focus where it belongs. :-)

Here's the entirety of my makeAuthorizeFn higher-ordered function:

export function makeAuthorizeFn(res) {
  return async function authorize({ username, password }) {
    const client = makeClient()

    try {
      const {
        data: {
          access,
          refresh,
        },
      } = await client.post('/auth/tokens', {
        email: username,
        password,
      })

      // This is a bit hacky, but at least it gets the tokens back
      // to the client.
      res.setHeader('Set-Cookie', [
        serialize('access', access, { path: '/' }),
        serialize('refresh', refresh, { path: '/' }),
      ])

      const { data: user } = await client.get('/users/me', {
        headers: {
          Authorization: makeAuthHeader(access),
        }
      })

      // N.B.: Adding fields to the user object at this point does not work

      return user
    } catch (error) {
      console.error('ERROR: %o', error)
      return null
    }
  }
}

Just for grins and giggles, I tried adding user.foo = "hello, world" where I currently have the N.B. comment, but when the session is dumped, the foo datum is nowhere to be found:

{"user":{"name":"Dan Kreft","email":"blarg@foo.com","image":null},"expires":"2020-07-24T23:20:32.599Z"}

This is what I was referring to in my previous comment.

I cannot simply use the jwt callback, because that callback does not have access to the tokens that were returned to me by the service.

@iaincollins
Copy link
Member

iaincollins commented Jun 25, 2020

Updated example below!

This is what your callbacks need to look like:

callbacks: { 
    session: async (session, token) => {
      // Copy properties from token contents to the client side session
      //
      // By default only 'safe' values like name, email and image which are
      // typically needed for presentation purposes (e.g. "you are logged in as…")
      // to avoid exposing sensitive information to the client inadvertently.
      session.user.data = token.user.data
      return Promise.resolve(session)
    }
}
  • The user object returned from the authorize callback is saved to the JWT (e.g. in token.user).
  • The session() callback controls what data is exposed from the JWT to the client session.

The stuff I wrote about the user response being wrong was totally incorrect! This amend example above works :-)

The example in the documentation I wrote for the Credentials plugin just a has a bug in it 🤦‍♂️

@dkreft
Copy link
Author

dkreft commented Jun 25, 2020

Note that async is not necessary (it actually causes eslint to complain) if you don't have an await in the function. I'll have another look at your proposed solution.

@dkreft
Copy link
Author

dkreft commented Jun 25, 2020

Okay, now I see what's going on here. It's a little confusing the way it's laid out here because I never would have thought that the session() callback's second argument would contain the thing returned by authorize().

At this point, though, I'm thinking that I'm probably going to stick with my current res.setHeader() solution because at least with the way I have it laid out now, my client doesn't have to worry about extracting the tokens from the session and figuring out where to put them (e.g. in cookies or in localStorage)...it only has to concern itself with pulling the tokens out of their respective cookies.

Thanks for your diligence...I feel more confident in using NextAuth knowing that you're so attentive. :-)

@iaincollins
Copy link
Member

Note that async is not necessary (it actually causes eslint to complain)

The purpose of it whenever I give an example code is to make it clear the function is async and a promise is expected.

(Otherwise people then immediately ask "How can I do async calls?")

Okay, now I see what's going on here. It's a little confusing the way it's laid out here because I never would have thought that the session() callback's second argument would contain the thing returned by authorize().

To clarify, as per the docs it's always the contents of the JSON Web Token as the second argument to the session() callback. It is present if, and only if, JWT sessions are enabled. This is not always the same as what is returned by authorize() as what is saved to the JWT can be overridden in the jwt() callback.

For users who are using other configurations, other data will be stored in the JWT (e.g. a user object from a database and/or the profile response from an OAuth Provider).

The session() callback provides provide a way selectively expose things to the client securely, in a simple and uniform away that works for all providers and is almost certainly less code and more performant than other solutions.

my client doesn't have to worry about extracting the tokens from the session and figuring out where to put them (e.g. in cookies or in localStorage).

Any properties accessed via a session object are be automatically kept up to date, and kept in sync across tabs and windows, and are persisted across page navigation in a single page app so that people can avoid doing exactly this (which actually, you have done - you have put tem.

I'd recommend to anyone else reading this they use the session property, if nothing else it's much less code to maintain and makes it easier to avoid bugs.

To confirm for anyone reading this in future, this is all you need to do to add data to a session from if you return it in a user object from authorize():

callbacks: { 
  session: (session, token) => {
    session.user.data = token.user.data
    return session
  }
}

If you do that, they will be there when you access the session object from the client.

@dkreft
Copy link
Author

dkreft commented Jun 27, 2020

@iaincollins I'm taking another look at using the session (I was ignorant of getSession()) and this looks promising, but I don't see a way to update data in the session after login. If I'm to store my tokens in the session, I need to be able to write to it after I refresh my access token. I've tried Googling for a solution, but to no avail. Is there something else I'm missing?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working question Ask how to do something or how something works
Projects
None yet
Development

No branches or pull requests

2 participants