-
-
Notifications
You must be signed in to change notification settings - Fork 2
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
Comments
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:
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 |
@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 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 |
@MrLeebo good idea! 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 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'}
}
}
}),
],
} |
@flybayer This would be great! That'd also solve other use-cases - like using Auth0 + JWT tokens from CLIs or mobile apps. |
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. |
@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. |
Hey @flybayer, I tried adding that middleware to my blitz app but when I try to access the session by doing |
@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 |
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. |
@flybayer I understand I need to call the |
@flybayer I just want to force the session as authenticated for any api as long as an admin token is present. 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 |
@tafelito Your custom middleware should go before sessionMiddleware in this case. sessionMiddleware will only set |
@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.
but when the api is called externally, this is logged
Not sure if has anything to do with the issue Btw, the |
@tafelito Ah! middleware does not run automatically for API routes. You need to use |
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? |
@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? |
@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 |
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:
Additional context
None.
The text was updated successfully, but these errors were encountered: