session-like stateless auth
refresh_token
support for PostGraphile with silent refreshExperimental refresh_token
support for PostGraphile. Users get session-like UX including multiple tab support, but without the server having to maintain centralized session state (e.g. Redis/DB). We save nothing in local storage, and don't do a DB look up on every request.
AFAICT PostGraphile currently supports
pgClient
. Great! 💯The reading story is already solid and this project doesn't set out to change that.
I'm trying to improve the story on issuing with stronger browser security in mind. My goal is to keep the documented usual auth flow that issues an access_token
'in band', but also introduce a side effect of setting an 'out of band' refresh_token
path'd cookie (aka 'silent refresh').
git clone [email protected]:onpaws/refresh-token-postgraphile
, install Node deps (whatever way you like e.g. yarn
), and start up Postgres.demo
, so please run:$ createdb demo
$ psql -d demo < db.sql
$ cd server; yarn; yarn start
(from another terminal)
$ cd client; yarn; yarn start
Heads up! When starting the frontend, CRA will open http://localhost:3000 which we don't want (CORS).
Please point your browser instead to http://localhost:4000
Because JWTs are stateless, you may be able to drop centralized session storage (Redis/DB). This project pushes the token state out strictly to live on the client only. #war-on-state
User lookups in the DB happen only in three situations now, not on every request, which would be not great. More on that below.
Real talk: unless you're running at Reddit scale these so-called 'stateless sessions' probably won't make a lick of difference to your scaling story. But yes, at least in principle, it's true your server should need less memory to serve the same # of users.
We try to improve the security situation by:
refresh_token
to live 'out of band':
refresh_token
via GraphQLHttpOnly
cookie flag so browser JavaScript will never see it (may help mitigate XSS)SameSite
, Secure
)refresh_token
. (Possibly overkill. Would be curious your opinion on this)refresh_tokens
/silent refresh feel free to copypasta the relevant bits as you see best.This project comprises three pieces:
/access_token
endpointBecause the access_token
is deliberately short-lived, we introduce apollo-link-token-refresh for silent refresh. Since this is GraphQL, when the link detects token expiry it fills a blocking operation queue in the background and smoothly handles the token refresh + queue release in with no UX impact.
JWT issuance requires a user lookup, which hits the DB. This happens in three cases:
access_token
expires (defaults to 15m, configurable. uses refresh_token
)refresh_token
)We don't hit the DB/Redis on every request like with traditional sessions
The FE is careful to avoid using local storage, but refreshing the page still feels like a session because useAuth
fetches a new access_token
when the FE presents a valid refresh_token
thanks to the cookie.
The refresh_token
cookie is deliberately path-scoped so browser doesn't send the token on every request, which would defeat the point of trying to restrict it's presence on the wire.
I previously stood up an Apollo Server-based project doing the same thing and wanted to port it to PostGraphile. As I understand it the access_token
/refresh_token
concept may have been inspired by OAuth2.
In the end we shift the session from living on a centralized store on the server into two tokens living strictly in the end user's browser. The tradeoff is more requests to the /access_token
endpoint, in return for giving up the requirement to manage a centralized session store. Whether this tradeoff is worth it is up to you.
access_token | refresh_token |
---|---|
ephemeral (e.g. 15 min) | persisted (e.g. 7 days) |
delivered in the usual way via GQL | set as a path'd cookie, never accessible by browser JavaScript |
lives in browser/JS memory only, never persisted to local storage | never accessible to browser/JS, also never persisted to local storage, lives as an 'out of band' cookie |
refreshing the page means FE has to fetch a new access_token | offers HttpOnly , Secure , SameSite
|
if compromised, short lifetime may help reduce blast radius | path'd cookie means it's only sent when that path is requested |
Where possible using strict CSP/HTTP security headers should bring additional risk mitigation.
So there you have it, refresh_token
based auth for PostGraphile!
isDev
flag or otherwise setting up a different mutation for local dev.access_token
lifetime.generate_token_plaintext()
doesn't return a token for that user, and wait 15 minutes. That's it. (Deleting the user would do the trick, as could setting up some kind of eg. active
flag if you wanted to keep the record.)This project uses http-proxy
to put everything into a single origin to punt on tedious CORS config.
Make sure you're pulling up the FE via http://localhost:4000.
mutation RegisterPerson {
registerPerson(input: {firstName: "Liam", lastName: "Gleesome", email: "[email protected]", password: "[email protected]"}) {
person {
id
}
}
}
mutation Authenticate {
authenticate(input: { email: "[email protected]", password: "[email protected]" })
}
# Tip: to subsequently run authenticated queries in GraphiQL, remember to paste token output into the field that pops up when you hit the 'Header' button. Remember it will expire in 15 minutes by default.
mutation CreateTodo {
createTodo(input: {todo: {todo: "Todo 1"}}) {
todo {
nodeId
}
}
}
query Todos {
todos {
edges {
node {
id
nodeId
todo
person {
firstName
}
}
}
}
}
1 Unlike with sessions, if a JWT gets compromised server operators have restricted options -- JWTs can be considered 'non-revocable'. Well, kind of - technically you could 'revoke' all JWTs by rotating the signing secret, but this kills everyone's token, not just the compromised one.