Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

door-auth

This is the front door I built so remote agents could reach my home MCP server without a hosted IdP. Claude's remote MCP connector wants a real OAuth 2.1 server on the other end -- discovery, dynamic client registration, PKCE, the works -- and every guide assumes you'll point it at Auth0 or Okta. I didn't want a third party sitting between an agent and my own machine, so I wrote the whole layer myself: one file, express and node:crypto, nothing else.

It runs in front of my home server in real life. This repo is the extracted auth layer plus a stub resource so you can walk the entire flow with curl on your laptop.

the flow

agent                         door-auth                        resource
  |                               |                               |
  |-- POST /mcp (no token) ----->|                               |
  |<- 401 + WWW-Authenticate: resource_metadata=...              |
  |-- GET /.well-known/oauth-protected-resource --> who's the AS |
  |-- GET /.well-known/oauth-authorization-server -> endpoints   |
  |-- POST /register -----------> client_id (a signed JWT)       |
  |-- GET /authorize + PKCE ----> login form, human signs in     |
  |<- 302 redirect with code                                     |
  |-- POST /token + verifier ---> access + refresh tokens        |
  |-- POST /mcp (Bearer) ------->|-- req.user set -------------->|

The 401 pointer is the trick that makes it automagical: an MCP client that hits the door without a token gets told exactly where the metadata lives, and from there it discovers registration, authorize, and token on its own. No pre-shared client config, no manual setup on the client side at all.

why from scratch

Partly because the hosted options put someone else's uptime and someone else's account between me and my own hardware. But mostly because the layer is small enough that a library obscures more than it saves. The specs involved (RFC 8414, RFC 9728, RFC 7591, PKCE from RFC 7636) are dense but rule-governed, and the rules are the spec. The one design choice I like most: client registration is stateless. The client_id handed out by /register is itself a signed JWT embedding the registered redirect_uris, so there is no client database to store, back up, or leak. Authorization codes work the same way. The only persistent state in the whole system is a 32-byte signing key and the scrypt hashes of a few passphrases.

Tokens are HS256 JWTs signed with node's own HMAC -- no jose dependency. The verify path pins the algorithm, checks the signature with timingSafeEqual, and refuses anything expired or from the wrong issuer. Everything fails closed: no signing key or no users means every endpoint refuses.

run it

npm install
node server.js

Then in another terminal:

bash test.sh

test.sh walks the whole ladder -- discovery, DCR, PKCE authorize and token exchange, a refresh grant, and an authenticated call -- printing PASS/FAIL for each step. Needs curl and node. The demo user is alice with the passphrase "correct horse battery staple".

Tunables live in config.json next to server.js (port, issuer_url, TTLs, allowed browser origins, demo users); copy config.example.json and edit. Missing file or bad values fall back to compiled defaults.

security notes -- what this does NOT do

Be honest about the scope before you point it at anything real:

  • No token revocation. Access tokens are stateless JWTs; once issued they are good until they expire. Keep the access TTL short. Refresh tokens can't be revoked either, short of rotating the signing key (which logs everyone out at once -- that IS my revocation story, and for a personal server it's fine).
  • Refresh tokens don't rotate. A real deployment of RFC 9700 guidance would rotate the refresh token on every use and detect replay. Mine doesn't.
  • No rate limiting on the login form. Put something in front of it, or use long passphrases (scrypt makes each guess expensive, but it won't save a six-character password).
  • No scopes beyond one. Every token gets the same single scope. Per-user authorization happens inside the resource server, keyed off req.user.
  • The demo harness hashes plaintext demo passwords at boot. That is demo convenience only. For real use, pre-hash with hashPassphrase() and store only the hashes, mode 600.
  • The signing key in the demo is ephemeral -- tokens die with the process. Persist a real key for real use.
  • HTTPS is your problem. The door itself speaks plain HTTP; in my setup TLS is terminated in front of it. Never run the authorize form over bare HTTP across a network you don't own.

None of these are accidents; they're the boundary of what a one-person server needs. As far as I know the core flow is correct against the RFCs, and the test ladder checks the refusal paths (bad PKCE verifier, wrong passphrase, unregistered redirect_uri, garbage bearer, evil Origin), not just the happy one. But it has not been audited by anyone but me, and so forth. Read auth.js before you trust it -- it's one file on purpose.

MIT, see LICENSE.

About

From-scratch OAuth 2.1 for a remote MCP server: RFC 8414/9728 discovery, dynamic client registration, mandatory S256 PKCE

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages