No description
- JavaScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`/protected` accepted a token with no scheme at all. The middleware pulled
the token out with `authHeader.replace('Bearer ', '')`, and replace leaves a
header that never had the prefix untouched, so `Authorization: <jwt>` passed
exactly like `Authorization: Bearer <jwt>`. Confirmed against the running
server before and after; the test suite now asserts the refusal.
The sample stored its passwords as passwords. A sample is the first place
somebody copies a pattern from, and a plain-text password column is the wrong
pattern to hand them, so they are scrypt hashes now - `scrypt:<salt>:<hash>`,
with scrypt from node:crypto, which costs no dependency. Three things that
go with it:
- the asynchronous scrypt, because scryptSync blocks the event loop, and so
the whole server, for the length of every login attempt
- crypto.timingSafeEqual, because a byte-by-byte === leaks through its own
duration how much of the hash was right
- a hash computed even for an unknown username, against a record nobody has
the password to, so response time does not say which accounts exist
npm audit went from 7 findings to zero. Two of them mattered here more than
most: a critical ReDoS in koa, and `jws: Improperly Verifies HMAC Signature`
in the jsonwebtoken tree - a signature-verification hole inside a sample
about verifying signatures. koa-router, which is unmaintained, was replaced
by @koa/router, and 15.7 rather than 14 because 14 has an access-control
bypass of its own.
Also:
- Unhandled errors reached Koa's default handler. There is an error
middleware now, which keeps internals out of the response body and logs a
4xx as one line while still giving a 5xx its stack.
- /login took `username` and `password` on trust. A non-string password -
`{"password": {"$ne": null}}` - reached scrypt, and a missing body reached
a destructure. Both are a 400.
- PORT and CORS_ORIGIN are read from the environment; the origin was pinned
to localhost:3000, so the sample only worked for one front end.
- The server refuses to start when NODE_ENV=production and JWT_SECRET is
unset. The development fallback exists so the sample runs, not so it ships.
- An occupied port ended in an unhandled exception dump.
- router.allowedMethods() is mounted, so a wrong method gets a 405 rather
than a 404.
Added test/smoke.mjs, 14 checks that walk the whole flow and every refusal -
wrong password, unknown user, missing prefix, missing header, a token signed
with another key, an alg=none token, an expired token - plus a CI workflow
that runs it and checks the production guard. README now documents the
environment, how to try it, and what the sample is trying to get right,
including the one thing it still does not: nothing rate-limits /login.
|
||
| .forgejo/workflows | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| index.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Koa-JWT-Example
just a sample of how JWT works via Koa.JS web framework
Table Of Contents
Built With
- jsonwebtoken (the JWT library)
- koa (Koa web framework)
- koa-bodyparser (Koa middleware for parsing HTTP request body)
- @koa/router (the Koa router;
koa-routeritself is unmaintained) - @koa/cors
node:cryptofor password hashing, so that costs no dependency
Usage
npm ci
cp .env.example .env # optional
npm start
npm test # walks the auth flow, including the refusals
| Variable | Default | Meaning |
|---|---|---|
JWT_SECRET |
insecure-development-key |
Signing key. Required when NODE_ENV=production, where the server refuses to start without it. |
PORT |
4000 |
Port to listen on. |
CORS_ORIGIN |
http://localhost:3000 |
Origin allowed by CORS. |
Try it
TOKEN=$(curl -s -H 'Content-Type: application/json' -d '{"username":"azer","password":"password1"}' localhost:4000/login | sed -E 's/.*"token":"([^"]+)".*//')
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/protected
What this sample tries to get right
A sample is the first place somebody copies a pattern from, so the patterns here are meant to be the ones worth copying:
- Passwords are stored as scrypt hashes, as
scrypt:<salt>:<hash>, never as the passwords.node:cryptoprovides scrypt, so this needs no dependency. The asynchronous form is used:scryptSyncwould block the event loop - the whole server - for the length of every hash. - The comparison is
timingSafeEqual. A byte-by-byte===leaks, through how long it takes, how much of the hash was right. - An unknown username costs the same as a known one. A hash is computed either way, so response time does not reveal which accounts exist.
- The verifying algorithm is named, not taken from the token's own header.
A verifier that accepts
alg: noneis the classic way a JWT check gets talked out of checking anything; the test suite tries exactly that. - The
Bearerprefix is required.authHeader.replace('Bearer ', '')leaves a header that never had the prefix untouched, which acceptedAuthorization: <token>as readily as the real thing. - The signing key comes from the environment, and the server refuses to start in production without one.
Still missing, because it needs more than a sample's worth of machinery: rate
limiting on /login. Nothing here slows down an attacker working through a
password list.
Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated.
- If you have suggestions for adding or removing projects, feel free to open an issue to discuss it, or directly create a pull request after you edit the README.md file with necessary changes.
- Please make sure you check your spelling and grammar.
- Create individual PR for each suggestion.
- Please also read through the Code Of Conduct before posting your first idea as well.
Creating A Pull Request
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request