Administer a FoxAuth OAuth 2.1 / OpenID Connect server: projects, apps, users, keys and settings.
Source-available OAuth 2.0 and OpenID Connect authorization server — built with Bun and TypeScript.
</a>
<a href="https://codecov.io/gh/RedFox-Soft/OAuth-server.ts">
</a>
<a href="https://github.com/RedFox-Soft/OAuth-server.ts/actions/workflows/security.yml">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/RedFox-Soft/OAuth-server.ts">
</a>
Website · Features · Docs · Compare · OAuth 2.0 · OpenID Connect · Bun · FSL-1.1-ALv2 License
OAuth-server.ts is a source-available (FSL-1.1-ALv2), standards-compliant authorization server written in TypeScript and powered by Bun and Elysia. It implements OAuth 2.0 and OpenID Connect from the ground up, giving you complete control over your identity infrastructure — with no vendor lock-in.
Most protocol capabilities are governed by a named feature flag, and a flag that is off means the capability's endpoint is not served at all. So this list is split by what you get on a default install versus what you switch on deliberately — the distinction is load-bearing, not editorial.
iss on every authorization response (RFC 9207)resource (RFC 8707)offline_access is among the supported scopes, which it is by defaultbun run db:setup provisions an initial RS256 key, and ES256 and EdDSA keys are supported. Keys live in the database, are generated on first run, and are published at /jwks — and each project with an address of its own publishes its own at its own jwks_uribun run db:setup. There is no static client configuration filesub values, salted from the databasePermissions-Policy, framing and content-type protectionsaddons.override()Implemented, and off until the flag is set. Set them in the console under Settings or on
ApplicationConfig; a change saved in the console applies at once.
| Capability | Flag |
|---|---|
| Client Credentials grant | clientCredentials.enabled |
| DPoP — sender-constrained tokens, including nonces (RFC 9449) | dpop.enabled |
| Pushed Authorization Requests (RFC 9126) | par.enabled |
| Token introspection (RFC 7662) | introspection.enabled |
| Token revocation (RFC 7009) | revocation.enabled |
| JWT-response introspection (RFC 9701) | jwtIntrospection.enabled |
| Signed UserInfo responses | jwtUserinfo.enabled |
| Dynamic client registration (RFC 7591) | registration.enabled |
| Registration management (RFC 7592) | registrationManagement.enabled |
| Device Authorization Grant (RFC 8628) | deviceFlow.enabled |
| CIBA — backchannel authentication | ciba.enabled |
| JARM — JWT-secured authorization response | responseMode.jwt.enabled |
Request objects (request / request_uri) | requestObjects.enabled |
| Rich Authorization Requests (RFC 9396) | richAuthorizationRequests.enabled |
| mTLS client authentication and certificate-bound tokens (RFC 8705) | mTLS.enabled |
| FAPI profile behaviours | fapi.enabled |
| Backchannel logout | backchannelLogout.enabled |
The claims request parameter | claimsParameter.enabled |
| Token and response encryption | encryption.enabled |
| Sign in with Google, Microsoft, Apple or GitHub, or any other OIDC provider by hand | federation.enabled |
| Administration over MCP — the same management API, served to an AI agent | mcp.enabled |
| SCIM 2.0 provisioning of a project's users and groups from Microsoft Entra ID, Okta or any SCIM client | scim.enabled |
| Okta Universal Logout — global token revocation (an expired individual draft, hence opt-in) | globalTokenRevocation.enabled |
| Error-store recording (its read surface is always served, deliberately) | errorStore.enabled |
Prerequisites: Docker with Compose v2.
curl -O https://raw.githubusercontent.com/RedFox-Soft/OAuth-server.ts/main/docker-compose.yml
docker compose up
That starts MongoDB, provisions the schema and the admin panel (an idempotent one-shot step), and
serves the server at http://localhost:3000. Open http://localhost:3000/admin — the first visit
shows a one-time setup screen that creates the initial super administrator. Discovery is at
http://localhost:3000/.well-known/openid-configuration. Set ISSUER in docker-compose.yml to its
final public URL before this first docker compose up: the seeded admin client's redirect URI is
derived from it once, and a later change is not repaired by re-running setup.
Images are published to ghcr.io/redfox-soft/oauth-server-ts on every release, tagged with the
version and latest. To upgrade, pull and run docker compose up again; the setup step re-runs and
is safe to repeat.
Prerequisites: Bun v1.3+ and a running MongoDB or PostgreSQL instance.
git clone https://github.com/RedFox-Soft/OAuth-server.ts.git
cd OAuth-server.ts
bun install
# Create .env — set MONGODB_URI, DATABASE_NAME, and ISSUER (see Configuration)
bun run db:setup # collections, indexes, initial RS256 signing key, admin panel seed
bun run build # bundles the console and sign-in screens into public/
bun start # http://localhost:3000
Schema provisioning creates the initial signing key, so no key configuration is required. If the server ever starts against an empty key store it also generates and persists one automatically.
bun run db:setup also provisions the admin panel: the reserved "Administrators" project and the
first-party admin-panel OAuth client. The seed is idempotent — re-run bun run db:setup after
upgrading an existing install so the admin client and project exist.
On first visit to /admin, a one-time setup screen creates the first administrator and makes them a
member of Super administrators; the setup route is closed once that team has an active member. The admin panel requires a database-backed deployment, MongoDB or PostgreSQL
(the in-memory adapter does not persist seeded data across restarts).
| Variable | Description | Example |
|---|---|---|
MONGODB_URI | MongoDB connection string | mongodb://localhost:27017 |
DATABASE_NAME | Name of the database to use | OAuth |
ISSUER | Canonical URL of your authorization server | https://auth.example.com |
Signing keys are stored in the database, not in an environment variable — every issuer's in the
bucketKeys area, the instance's included. The initial RS256 signing key is created when you
provision the schema (bun run db:setup); the server also generates and persists one automatically
if it starts against an empty store. Rotation needs no restart: generate a key (it is published
first), promote it to sign once every instance serves it, and retire the old one, which keeps
verifying for a day — in the console, the admin API, or over MCP (jwks_generate, jwks_promote,
jwks_retire). One key signs per algorithm, so RS256 and PS256 can sign side by side.
Requests are counted per calling origin and refused with 429 once an origin exceeds its allowance
inside a window. The refusal happens before the endpoint does any work, and carries Retry-After
with the seconds remaining; the body is the standard OAuth error shape, the admin plane's own shape,
or an HTML page, depending on which surface was addressed. No RateLimit-* headers are emitted —
they would tell a caller probing for the threshold exactly where it is.
Allowances are tiered by route class rather than being one blanket number, because a limit tight enough to protect the token endpoint would refuse the console's asset burst:
| Class | Applies to | Default |
|---|---|---|
strict | Token, authorization, dynamic registration, PAR, device, CIBA, and every end-user door that checks a secret or sends mail | 60 / 60s |
ordinary | Everything not otherwise classified — userinfo, introspection, revocation, the admin API, MCP, the rest of the interaction screens | 300 / 60s |
public | Static assets, discovery metadata, the key set, and every cross-origin preflight | 1200 / 60s |
| exempt | GET /health only — a refused liveness probe would take the machine out of the load balancer | never counted |
All of it is settable from the admin console under Settings → Rate limiting, or directly on
ApplicationConfig. A change saved in the console applies at once, like every other server setting.
| Setting | Default | Notes |
|---|---|---|
rateLimit.enabled | true | Incident kill switch |
rateLimit.trustedProxy | true | See below — no universally safe value |
rateLimit.maxTrackedOrigins | 10000 | Per class; caps the limiter's own memory |
rateLimit.strict.max | 60 | With .windowSeconds, default 60 |
rateLimit.ordinary.max | 300 | With .windowSeconds, default 60 |
rateLimit.public.max | 1200 | With .windowSeconds, default 60 |
The server refuses to start if any of these is not a positive integer, rather than serving with limiting silently absent or silently total.
rateLimit.trustedProxy is the one setting with a wrong answer in each direction. Leave it on
when anything sits in front of this server (the shipped fly.toml does): with it off, every caller
arrives as the proxy's own address, so the whole internet shares one allowance and all traffic is
refused within seconds. Turn it off when the server is directly exposed: with it on, any caller can
set Fly-Client-IP to a fresh value per request and is never limited.
Two properties to know before relying on this. Counting is per instance and held in memory, so with N machines serving concurrently the effective allowance is N times the configured value, and an instance restart clears every counter. This is a resource protection — it bounds what one source can cost the server — and not a security boundary. The limits that must hold absolutely are the per-identity throttles: verification codes cap at five attempts, resends and password-reset requests are cooldown- and daily-capped, and the password sign-in door has the brute-force throttle below — all independently of anything here.
Tuning. Raise strict.max if a legitimate server-to-server integration behind one address, or
many users behind one corporate NAT, start seeing refusals. Raise public.max if the admin console
stutters while loading. Lower maxTrackedOrigins under memory pressure. Set rateLimit.enabled to
false to switch the whole thing off during an incident; it applies on save.
Failed password attempts are counted per project and address. Once an address reaches the cap, the sign-in door is shut for that address and refuses every further attempt — including one carrying the correct password — until the window ends. Each further exhaustion shuts it for longer, doubling to the ceiling: 15 → 30 → 60 minutes by default, which holds a sustained attack to roughly 120 guesses a day. The counter is forgotten after 24 hours without a failure.
The refusal is the ordinary "invalid username or password" page. It never says that a throttle exists, how many attempts remain, or when the door reopens, because saying any of it would tell an attacker that the address they are guessing is real. For the same reason attempts are counted for addresses that have no account at all — a counter's existence means somebody typed that address, nothing more. And because the refusal happens before the account lookup, a refused attempt costs no password hashing: a flood against one address stops being a CPU cost as well as stopping being a guessing opportunity.
Unlike the per-origin limiter above, these counters are persisted, so the limit holds across restarts and across every machine serving concurrently. That is what makes this a security boundary rather than a resource protection — and why there is deliberately no switch to turn it off.
| Setting | Default | Notes |
|---|---|---|
loginThrottle.failureCap | 5 | Attempts per window; at most 100 |
loginThrottle.windowSeconds | 900 | First lockout, and the base the curve doubles from |
loginThrottle.windowCeilingSeconds | 3600 | Longest lockout; never below the first, never over 24h |
Two counters are cleared immediately rather than waiting out the window: a password that verifies, and a completed password reset. The second is the escape hatch — consuming the emailed secret proves control of the address, which is exactly what an attacker guessing passwords does not have — so a locked-out user is never stuck waiting. Merely requesting a reset clears nothing.
A project that requires a one-time code stays at the first window however often it is tripped: a guessed password there does not sign anyone in, so the deeper lockouts buy little. This matters most for the reserved Administrators project, whose operators sign in through this same door and have no self-service reset to escape with — their worst case is a 15-minute wait, and the way to make that door properly hard is to require the second factor on it (Admins → Require an authenticator app).
Published image:
docker pull ghcr.io/redfox-soft/oauth-server-ts:latest
docker run -p 3000:3000 --env-file .env ghcr.io/redfox-soft/oauth-server-ts:latest
Run bun run db:setup against the same database once before the first start (the Compose file
above does this for you).
Every published image is signed over its digest, and carries an SBOM and a build provenance attestation. The signing is keyless, so verification names the workflow that is allowed to have produced it rather than a key:
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/RedFox-Soft/OAuth-server\.ts/\.github/workflows/release\.yml@refs/tags/v' \
ghcr.io/redfox-soft/oauth-server-ts:latest
Reading the SBOM, the build record and the signed SLSA provenance is three more commands.
To build locally instead:
docker build -t oauth-server-ts .
docker run -p 3000:3000 --env-file .env oauth-server-ts
Most endpoints are governed by a feature flag, and a flag that is off means the endpoint is not
served at all — not merely that discovery stops advertising it. The refusal is deliberately
indistinguishable from a path the server does not have, so if a request below returns 404, check the
flag before suspecting a defect. Flags are set in the admin console under Settings, or directly on
ApplicationConfig, and a change saved in the console applies at once.
| Endpoint | Description |
|---|---|
GET /health | Liveness probe |
GET /.well-known/openid-configuration | OpenID Connect discovery document (contents reflect enabled capabilities) |
GET /.well-known/oauth-authorization-server | Authorization server metadata for plain OAuth clients (RFC 8414); the same document minus the OpenID-Connect-only members |
GET /.well-known/security.txt | Security contact and disclosure policy (RFC 9116) |
GET /jwks | JSON Web Key Set |
GET, POST /auth | Authorization endpoint |
POST /token | Token endpoint |
Flag-governed, but on unless you turn them off.
| Endpoint | Description | Flag |
|---|---|---|
GET, POST /userinfo | UserInfo endpoint | userinfo.enabled |
GET, POST /logout | RP-initiated logout | rpInitiatedLogout.enabled |
POST /logout/confirm | Logout confirmation | rpInitiatedLogout.enabled |
Absent from a default install until the flag is set.
| Endpoint | Description | Flag |
|---|---|---|
POST /par | Pushed Authorization Requests (RFC 9126) | par.enabled |
POST /token/introspect | Token introspection (RFC 7662) | introspection.enabled |
POST /token/revocation | Token revocation (RFC 7009) | revocation.enabled |
POST /reg | Dynamic client registration (RFC 7591) | registration.enabled |
GET /reg/:clientId | Read a client's own registration | registration.enabled |
PUT, DELETE /reg/:clientId | Update / delete a registration (RFC 7592) | registrationManagement.enabled |
POST /device/auth | Device authorization (RFC 8628) | deviceFlow.enabled |
GET, POST /device | User-code entry page for the device flow | deviceFlow.enabled |
POST /backchannel | Client-Initiated Backchannel Authentication (CIBA) | ciba.enabled |
POST, GET /mcp | Administration over MCP | mcp.enabled |
GET /.well-known/oauth-protected-resource/mcp | Protected resource metadata for /mcp (RFC 9728) | mcp.enabled |
GET, POST /federation/callback | Upstream identity provider callback | federation.enabled |
/scim/v2/* (Users, Groups and discovery) | SCIM 2.0 provisioning, at each project's address | scim.enabled |
POST /global-token-revocation | Okta Universal Logout, at each project's address | globalTokenRevocation.enabled |
Reading your own registration follows registration.enabled rather than
registrationManagement.enabled: the registration response hands the client that URI, so refusing the
read while registration is on would break the capability that issued it. Only the mutating methods
need the management flag.
Reached by redirect or through the console rather than called directly, so these are described as families rather than enumerated.
| Family | Entry point | Description |
|---|---|---|
| End-user interaction screens | /ui/:uid/… | Login, consent, registration, TOTP challenge and enrolment, forgot-password, flow resume. The federation legs (/ui/:uid/federation/…) need federation.enabled |
| Email verification | /verify-email | Verify, code entry, resend |
| Password reset | /reset-password | Self-service reset |
| Administration console + API | /admin, /admin/api | Projects, teams, clients, administrators, end-users, federation providers, settings, SMTP, signing keys, audit trail, error store |
| Static assets | /public/… | Console assets |
The interaction and self-service families are unconditional by design — a user locked out of their
account cannot be asked to wait for a capability toggle. The console is unconditional too, even though
/mcp is gated: switching MCP off must not take the console with it.
The server administers itself. GET /admin is a console for projects, teams, OAuth clients,
administrators, end-users, upstream identity providers, settings, signing keys and an
append-only audit trail. Administrators sign in through an OpenID Connect flow against this server's
own issuer, so there is no second password store.
The same management API is available to an AI agent over the Model Context Protocol, as an OAuth 2.1 protected resource of this server. It is off by default — the capability hands an agent the authority of the administrator who authorized it, so a deployment should switch it on deliberately:
https://your-server/mcp with client id admin-mcp and resource
https://your-server/mcp.An agent then acts as the administrator who signed in, with exactly that account's permissions. Every change it makes runs through the same routes, the same checks and the same audit trail as the console's, and each audit entry names both the operator and the agent.
A few things it deliberately cannot do: delete a project or a team, purge the fault store, and change which client identities may administer the instance. The deletions destroy a container with nothing left afterwards to inspect, and the last is a privilege-escalation path, so they stay console-only. Everything else destructive takes two steps — the agent describes what would change and you confirm that specific operation.
Which client identity works here. The reserved
client_idabove always works and needs no setup beyond the toggle. An agent host that cannot be told aclient_idcan use this surface too, provided it identifies itself with an OAuth Client ID Metadata Document — an HTTPS URL naming a document that describes it — and a super administrator has named that identity under Agent access in the console. A client that registered itself through Dynamic Client Registration cannot administer the instance under any configuration: its identity is minted on demand by whoever asked for it, so there is nothing an operator could meaningfully allowlist.
The same protocol surface works for an MCP server that is not this one. Declare it as a protected
resource of a project — its canonical URI, the scopes it recognises, how its tokens are verified —
and this server mints tokens whose audience is exactly that resource, with no code change here and no
restart. An agent host discovers it, signs your end-users in, and comes back with a token your MCP
server verifies against the jwks_uri its issuer's metadata names, without asking anything.
Protect your MCP server with OAuth walks it end to end — including what a correct refusal looks like, so you can tell "protected" from "not reached yet".
The Flag column names the capability's governing flag; — means always on. See
Features for defaults.
| Specification | Description | Flag |
|---|---|---|
| OAuth 2.1 | The profile targeted: PKCE for all clients, exact redirect-URI matching, refresh-token rotation, no Implicit or ROPC | — |
| RFC 6749 | OAuth 2.0 Authorization Framework | — |
| RFC 7636 | PKCE | — |
| RFC 7519 | JSON Web Token (JWT) | — |
| RFC 7517 | JSON Web Key (JWK) | — |
| RFC 9068 | JWT Profile for OAuth 2.0 Access Tokens | — |
| OIDC Core 1.0 | OpenID Connect Core | — |
| OIDC Discovery 1.0 | OpenID Connect Discovery | — |
| OIDC RP-Initiated Logout 1.0 | A client starts sign-out and gets the user back | rpInitiatedLogout.enabled |
| OIDC Back-Channel Logout 1.0 | Clients told of sign-out server to server, and upstream providers' sign-outs received | backchannelLogout.enabled, federation.enabled |
| RFC 8414 | OAuth 2.0 Authorization Server Metadata | — |
| RFC 9207 | Authorization Server Issuer Identification | — |
| RFC 8707 | Resource Indicators | resourceIndicators.enabled |
| RFC 7009 | OAuth 2.0 Token Revocation | revocation.enabled |
| RFC 7662 | OAuth 2.0 Token Introspection | introspection.enabled |
| RFC 9701 | JWT Response for OAuth Token Introspection | jwtIntrospection.enabled |
| RFC 9126 | Pushed Authorization Requests (PAR) | par.enabled |
| RFC 9449 | DPoP, including nonces | dpop.enabled |
| RFC 7591 | Dynamic Client Registration | registration.enabled |
| RFC 7592 | Dynamic Client Registration Management | registrationManagement.enabled |
| RFC 8628 | Device Authorization Grant | deviceFlow.enabled |
| RFC 8705 | mTLS Client Authentication and Certificate-Bound Tokens | mTLS.enabled |
| RFC 9396 | Rich Authorization Requests | richAuthorizationRequests.enabled |
| RFC 9728 | OAuth 2.0 Protected Resource Metadata | mcp.enabled |
| RFC 7643 | SCIM Core Schema | scim.enabled |
| RFC 7644 | SCIM Protocol | scim.enabled |
| CIBA | Client-Initiated Backchannel Authentication | ciba.enabled |
| JARM | JWT-Secured Authorization Response Mode | responseMode.jwt.enabled |
| RFC 6238 | TOTP second factor | — |
| Client ID Metadata Document | A client identified by a URL to a document it hosts | clientIdMetadataDocument.enabled |
Report a vulnerability to security@foxauth.dev or through private vulnerability reporting; SECURITY.md has the scope, the response times and the safe harbour.
The same file says what stands behind the code: a published threat model, CodeQL, dependency and container scanning on every push and every week (what runs and where to read it), signed images with an SBOM and build provenance, and an OpenSSF Scorecard. It also says what does not exist yet — no external audit, no paid bounty, no OpenID Foundation certification — because a security page that only lists the good news is not one you can trust.
Contributions are welcome! Please open an issue or pull request. For significant changes, open an issue first to discuss your proposal.
OAuth-server.ts is licensed under the Functional Source License, Version 1.1, ALv2 Future
License (FSL-1.1-ALv2) — the same license Sentry uses. In short: you may use, modify,
self-host and redistribute it for any purpose except offering it to others as a competing hosted
service, and each version converts to the Apache License 2.0 two years after its release.
This project began in April 2025 as a fork of node-oidc-provider by Filip Skokan, distributed under the MIT License. Its notice is preserved in NOTICE.
This listing does not have a supported local package template. Use the maintainer’s documentation for its hosted endpoint, authentication, and client-specific setup. No install command has been inferred.