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

Permanent Access Tokens for protected API routes #25

Open
hmbrg opened this issue Jul 30, 2021 · 17 comments
Open

Permanent Access Tokens for protected API routes #25

hmbrg opened this issue Jul 30, 2021 · 17 comments

Comments

@hmbrg
Copy link

hmbrg commented Jul 30, 2021

What do you want and why?

My team is using Retool.com to create internal tooling for our Blitz.js app.
The plan is to create API routes within the Blitz app and then send request to these endpoints from Retool.
Ideally, I could generate a personal access token like GitHub offers which can then be used to access protected routes.

Possible implementation(s)

@flybayer suggested on Discord that a workaround could be to create a new custom API route that verifies your access token. Then if good, it passes parameters on to the query/mutation like this:

await getUsers(
 params, 
 {session: {
   $authorize: () => {}, 
   $isAuthorized: () => true}
  }}
)

Additional context

None.

@roshan-sama
Copy link
Contributor

roshan-sama commented Jul 31, 2021

I've used retool as well, its really good for admin panel type tools. I've only done queries directly on the db though, but adding an API key to a route would be good too.

I'm wondering if we can modify resolver.authorize to take an argument that finds a hashed token, and compares the has of the token in the request to allow access to the mutation / query

As for how to provide the token, would it make sense to send it via a header? I assume that most api integrations support this, but I could be wrong. Retool's "openapi" resource at least allows us to specify this.

I was thinking something like this:

const authorize: ResolverAuthorize = ({tokenHash}: {tokenHash?: () => string}, ...args: unknown[]) => {
  return function _innerAuthorize(input, ctx) {
    if(tokenHash !== undefined){
      // console.log(tokenHash)

      if(tokenHash() !== "provided token from header"){
        console.log("token auth error")
        throw new AuthorizationError()
      }
    }
    const session: SessionContext = (ctx as any).session
    session.$authorize(...args)
    return {
      __blitz: true,
      value: input,
      // we could use {...ctx, session} instead of `as any` just for TypeScript's sake
      ctx: ctx as any,
    }
  }
}

Or maybe it should be a different function? I am not sure of the best way to add that token parameter and have the destructured roles work as well

@MrLeebo
Copy link
Member

MrLeebo commented Aug 1, 2021

@hmbrg You can create an access token using the Token table that is generated by the new blitz app template, give it a type of "ACCESS_TOKEN" and create middleware authenticate it. I don't think it would take much code to set up a basic implementation.

You could add middleware to the global middleware list:

 // blitz.config.ts
 import { BlitzConfig, sessionMiddleware, simpleRolesIsAuthorized } from "blitz"
+import accessTokenMiddleware from "app/auth/middleware/accessToken"

 const config: BlitzConfig = {
   middleware: [
+    accessTokenMiddleware,
     sessionMiddleware({
       cookiePrefix: "appName",
       isAuthorized: simpleRolesIsAuthorized,
     }),
   ],
 }
module.exports = config

Alternatively, you could add the middleware to the individual queries and mutations you want the token to grant access to.

The middleware would look for a Bearer token in the request headers and match it to one that has been granted:

// app/auth/middleware/accessToken.ts
import { Middleware, hash256, getSession } from "blitz"
import db from "db"
import { Role } from "types"

export class InvalidAccessTokenError extends Error {
  name = "InvalidAccessTokenError"
  message = "Access token is invalid."
  statusCode = 401
}

const accessToken: Middleware = async (req, res, next) => {
  // Ignore requests that don't contain a bearer token
  const { authorization } = req.headers
  if (!authorization) return next()

  const [authorizationType, credentials] = authorization.split(" ", 2)
  if (authorizationType !== "Bearer") return next()

  // Match the token against tokens we've issued
  const hashedToken = hash256(credentials)
  const possibleToken = await db.token.findFirst({
    where: { hashedToken, type: "ACCESS_TOKEN" },
    include: { user: true },
  })

  if (!possibleToken) throw new InvalidAccessTokenError()

  // Create a session so that downstream middleware and queries/mutations can authorize the user
  const session = await getSession(req, res)
  // NOTE: there is a problem with this I'll bring up at the end
  await session.$create({ userId: possibleToken.userId, role: possibleToken.user.role as Role })

  return next()
}

export default accessToken

Finally, we just need to create some access tokens and send them back in our web requests. To demonstrate, I'm just going to create a script that logs the token out to console when you run it, but in a real app you might send the token in an email or give it a UI treatment inside your app.

// scripts/accessTokenCreator.ts
import { generateToken, hash256 } from "blitz"
import db from "db"

// execute this script by running `npx blitz console` and copying the following command into the REPL:
//
//   await require('scripts/accessTokenCreator').create()
//
export async function create() {
  // Select the user to be granted an access token
  const user = await db.user.findFirst()
  if (!user) return console.error("Failed to grant access token. No such user.")

  // Generate an access token
  const token = generateToken()
  const hashedToken = hash256(token)
  await db.token.create({
    data: {
      user: { connect: { id: user.id } },
      type: "ACCESS_TOKEN",
      hashedToken,
      expiresAt: new Date(9999, 0),
      sentTo: user.email,
    },
  })

  // Send the token to the user
  console.log(`
    Token granted. Run the following command in the terminal to test it:

      curl -i http://localhost:3000/api/users/queries/getCurrentUser --data 'params=' -H "Authorization: Bearer ${token}"
  `)
}

When you run the script, it will print out a curl command you can run in your terminal to prove the token works.

That should give you a foundation for your own access token implementation.

The drawback to the way this works is that it will generate a Session record for each request, and those will start to pile up over time. You could have a nightly cron job that clears expired sessions out of the database (it is a good idea to have a job like this anyway), but it would be better if the session that was created by the access token was non-persisted, or saved in a cookie instead of the database.

Blitz has the ability to create non-persisted sessions, but it only does that when there isn't a user ID associated to the session. There doesn't appear to be a way to force it to make the session non-persisted if it has a user ID.

@flybayer Would it be reasonable to add a method to the session object to do that? e.g.

ctx.session.$createNonPersisted({ userId, role }) // cookie session with user ID

@flybayer
Copy link
Member

flybayer commented Aug 4, 2021

@MrLeebo good idea! accessTokenMiddleware needs to go after sessionMiddleware though if you want to access ctx.session.

Here's another workaround:

 const config: BlitzConfig = {
   middleware: [
     sessionMiddleware({
       cookiePrefix: "appName",
       isAuthorized: simpleRolesIsAuthorized,
     }),
     (req, res, next) => {
       if (process.env.ADMIN_TOKEN && req.headers['admin-token'] === process.env.ADMIN_TOKEN)) {
         // override sessionMiddleware for this request
         res.blitzCtx.session = {$authorize: () => {}, $isAuthorized: () => true}
       }
       return next()
     }
   ],
 }

I think this is a common enough use case that the ideal solution is add a feature to our sessionMiddleware that supports a token.

Maybe like this

 const config: BlitzConfig = {
   middleware: [
     sessionMiddleware({
       cookiePrefix: "appName",
       isAuthorized: simpleRolesIsAuthorized,
       authenticateOverride: ({req, res}) => {
         // this function is a no-op if null is returned
         if (!req.headers['admin-token'] || !process.env.ADMIN_TOKEN) return null
         if (req.headers['admin-token'] !== process.env.ADMIN_TOKEN) return null
         // If publicData returned, the normal auth process will be bypassed
         //    and no cookies will be read or created.
         return {
           // Same as argument to `session.$create()`
           publicData: {userId: null, role: 'ADMIN'}
         }
       }
     }),
   ],
 }

@malekjaroslav
Copy link

@flybayer This would be great! That'd also solve other use-cases - like using Auth0 + JWT tokens from CLIs or mobile apps.

@ethndotsh
Copy link

This would be incredibly useful in basically any production app, so I hope that before v1, this gets implemented.

But in the meantime, the methods provided should work just fine.

@MrLeebo
Copy link
Member

MrLeebo commented Nov 17, 2021

@cursecodes I don't think the base app template should necessarily be updated to include any of this. The base architecture is the most secure when it assumes that all fetches are same-origin only, which doesn't require access tokens. Blitz shouldn't make assumptions about which endpoints the developer is going to share with outsiders; you should always have to opt-in to that functionality, whether it's by adding a middleware such as these, or by running a blitz installer which adds it for you. Open up the endpoints you want to share, but don't give them unfettered access to the whole API.

@tafelito
Copy link

@MrLeebo good idea! accessTokenMiddleware needs to go after sessionMiddleware though if you want to access ctx.session.

Here's another workaround:

 const config: BlitzConfig = {
   middleware: [
     sessionMiddleware({
       cookiePrefix: "appName",
       isAuthorized: simpleRolesIsAuthorized,
     }),
     (req, res, next) => {
       if (process.env.ADMIN_TOKEN && req.headers['admin-token'] === process.env.ADMIN_TOKEN)) {
         // override sessionMiddleware for this request
         res.blitzCtx.session = {$authorize: () => {}, $isAuthorized: () => true}
       }
       return next()
     }
   ],
 }

Hey @flybayer, I tried adding that middleware to my blitz app but when I try to access the session by doing await getSession(req, res) from an api, I get a CSRFTokenMismatchError. Am I missing something?

@flybayer
Copy link
Member

flybayer commented Dec 6, 2021

@tafelito so sorry for the delay here. You need to include the csrf cookie value in a header like this: https://blitzjs.com/docs/session-management#manual-api-requests

@MrLeebo
Copy link
Member

MrLeebo commented Dec 6, 2021

I think the CSRF token error would be a consequence of changing the order of the middlewares, my initial example didn't require CSRF, and it shouldn't since the request is authenticated by the access token, not by a cookie.

@tafelito
Copy link

tafelito commented Dec 6, 2021

@flybayer I understand I need to call the getAntiCSRFToken when calling an api manually from the client. In this case I'm calling the api from a external client and sending a token in the header.
When the token is present, I need to set the session as authenticated before calling the api so the pipe.authenticate doesn't throw an authentication error

@tafelito
Copy link

@flybayer I just want to force the session as authenticated for any api as long as an admin token is present.
If I add the middleware as @MrLeebo suggested, when I check the session's isAuthenticated like suggested above from the api, it always returns false

As a workaround for now, I just check the header from the api function, but I'd like to do it in the middleware instead and prevent having to add the check in every api I need it

@flybayer
Copy link
Member

@tafelito Your custom middleware should go before sessionMiddleware in this case. sessionMiddleware will only set ctx.session if it's not already set. So if your middleware sets it, then sessionMiddleware will skip. See the code here: https://github.com/blitz-js/blitz/blob/canary/nextjs/packages/next/stdlib-server/auth-sessions.ts#L196-L199

@tafelito
Copy link

tafelito commented Dec 19, 2021

@flybayer I tried putting my middleware before sessionMiddleware but the session is still not set. One difference that I noticed tho is that I console.log from my custom middleware, the logs are printed only when calling an api from the same app but when calling it externally from a different client, those logs never get printed.
I also noticed is that when an api is called from the app, this gets printed

event - build page: /api/rpc/someQuery

but when the api is called externally, this is logged

event - build page: /api/webhook

Not sure if has anything to do with the issue

Btw, the webhook api is inside app/api/webhook and the someQuery is inside app/something/queries/someQuery

@flybayer
Copy link
Member

@tafelito Ah! middleware does not run automatically for API routes. You need to use invokeWithMiddleware

@tafelito
Copy link

So then it's not possible for an external service to call this api and use a middleware. Those same checks have to be in place in the api itself. Right?

@flybayer
Copy link
Member

@tafelito I'm not sure — can you share some code?

In general to authenticate a third party service, it's usually going to be easier to just do it inside the api route instead of using middleware. Because do you really want the third party service having full access to ALL your routes or just one or two endpoints?

@tafelito
Copy link

Because do you really want the third party service having full access to ALL your routes or just one or two endpoints?

@flybayer, it actually makes sense. I will probably have just one route, maybe 2 for different providers but I guess I could use a helper in that case as well.

Thanks again for the clarification

@itsdillon itsdillon transferred this issue from blitz-js/blitz Jul 7, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants