OAuth 2.0-compliant authentication gateway for the Bittensor ecosystem.
Authenticates Bittensor (sr25519) and Ethereum (EVM) wallets, issues RS256 JWTs, and verifies on-chain roles (miner, validator, subnet owner, TAO holder) against live Subtensor state — so any app or service can gate access based on what a wallet actually does on the network.
- Two wallet types: Bittensor (sr25519) and Ethereum (EVM via EIP-191
personal_sign) - Three auth flows: Challenge-response (simple), OAuth2 authorization code (web apps), Device code / RFC 8628 (CLIs)
- OIDC support: Discovery metadata + ID token issuance (
openidscope, optionalnonce) - On-chain scope verification: Validates miner, validator, owner, and holder roles against Subtensor state (Bittensor wallets)
- Epoch-aligned re-verification: Skips redundant on-chain calls within the same epoch
- PKCE support: Required for public clients; when
code_challengeis sent, this server requires explicitcode_challenge_method=S256 - Refresh token rotation: DB-backed with revocation support
- Client registration: Admin API for managing OAuth clients
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Client App │──────▶│ Auth Gateway │──────▶│ Subtensor │
│ (browser / │ HTTP │ (Fastify) │ WS │ (Finney) │
│ CLI) │ │ │ │ │
└──────────────┘ └──────┬───────┘ └──────────────┘
│
┌──────▼───────┐
│ PostgreSQL │
│ (clients, │
│ tokens) │
└──────────────┘
- Fastify HTTP server with RS256 JWT signing
- PostgreSQL for client registration and refresh token storage
- Subtensor WebSocket connection for on-chain state queries
- PostgreSQL for challenges, device codes, and consumed auth codes (DB-backed, multi-instance safe)
- Node.js 22 LTS
- PostgreSQL 14+
- Docker & Docker Compose (optional)
# Clone and install
git clone <repo-url> && cd auth-gateway
npm install
# Generate RSA keys
npm run generate-keys
# Configure environment
cp .env.dist .env
# Edit .env with your database URL, admin key, etc.
# Start PostgreSQL (via Docker Compose)
docker compose up -d postgres
# Run in development mode (auto-runs migrations)
npm run dev
# Or build and start production
npm run build
npm startThe fastest way to try the gateway — auto-creates demo clients and serves an interactive landing page:
# Using Docker Compose (recommended)
npm run demo
# Or manually (requires PostgreSQL + Subtensor)
DEMO_MODE=true npm run devVisit http://localhost:3000 to see the landing page with "Try it" buttons for the OAuth2 flow. The landing page handles the full PKCE flow inline — no separate client app needed.
See examples/ for standalone integration examples.
# Via the admin API
curl -X POST http://localhost:3000/v1/admin/clients \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: your-admin-key" \
-d '{
"client_name": "My App",
"client_type": "public",
"redirect_uris": ["http://localhost:3001/callback"],
"grant_types": ["authorization_code"],
"allowed_origins": ["http://localhost:3001"]
}'
# Or via the CLI script
npx tsx scripts/create-client.ts| Endpoint | Method | Description |
|---|---|---|
/v1/auth/challenge |
POST | Request a signing challenge for an address |
/v1/auth/verify |
POST | Verify signature and receive tokens |
| Endpoint | Method | Description |
|---|---|---|
/v1/oauth/authorize |
GET | Authorization page (browser redirect, response_type=code required) |
/v1/oauth/challenge |
POST | Create an OAuth-bound signing challenge (session_id required) |
/v1/oauth/callback |
POST | Internal: exchange signed challenge for auth code |
/v1/oauth/token |
POST | Exchange auth code for access + refresh tokens (+ id_token when openid is requested) |
/v1/oauth/refresh |
POST | Refresh token rotation |
PKCE policy: when sending code_challenge, clients must also send explicit code_challenge_method=S256. This server does not apply RFC 7636 defaulting.
| Endpoint | Method | Description |
|---|---|---|
/v1/device/code |
POST | Request a device code + user code |
/v1/oauth/token |
POST | Poll for token (CLI polling, grant_type=urn:ietf:params:oauth:grant-type:device_code) |
/v1/device/verify |
GET | User-facing verification page |
/v1/device/approve |
POST | Initiate approval (creates challenge) |
/v1/device/confirm |
POST | Confirm approval with signature |
/v1/device/scopes |
GET | Look up scopes for a user code |
Confidential clients must authenticate when calling /v1/device/code. Public clients may omit client_secret.
| Endpoint | Method | Description |
|---|---|---|
/v1/admin/clients |
POST | Register a new OAuth client |
/v1/admin/clients |
GET | List all registered clients |
/v1/admin/clients/:id |
PATCH | Update mutable client fields |
/v1/admin/clients/:id/rotate-secret |
POST | Rotate client secret |
/v1/admin/clients/:id |
DELETE | Deactivate a client |
| Endpoint | Method | Description |
|---|---|---|
/.well-known/openid-configuration |
GET | OIDC discovery document |
/.well-known/jwks.json |
GET | JSON Web Key Set |
/health |
GET | Health check (subtensor + database) |
- Discovery metadata is served at
/.well-known/openid-configuration. - Request the
openidscope in/v1/oauth/authorizeto receive anid_tokenfrom/v1/oauth/token. - Pass
nonceon/v1/oauth/authorizeto bind browser session state; the same value is returned in theid_tokenclaim. id_tokenaudience (aud) is the OAuth client ID; access/refresh tokens useJWT_AUDIENCE.- For OIDC consistency, keep
JWT_ISSUERaligned withPUBLIC_URL(enforced in production).
| Variable | Description | Default | Required |
|---|---|---|---|
PORT |
Server port | 3000 |
No |
HOST |
Bind address | 0.0.0.0 |
No |
NODE_ENV |
Environment | development |
No |
TRUST_PROXY |
Fastify proxy trust setting (false, true, hop count, or trusted proxy list) |
false |
No |
NETWORK |
Network mode (mainnet or testnet) |
mainnet |
No |
RSA_PRIVATE_KEY_PATH |
Path to RSA private key | — | Yes* |
RSA_PUBLIC_KEY_PATH |
Path to RSA public key | — | Yes* |
RSA_PRIVATE_KEY_BASE64 |
Base64-encoded private key | — | Yes* |
RSA_PUBLIC_KEY_BASE64 |
Base64-encoded public key | — | Yes* |
JWT_ISSUER |
JWT issuer claim (should match PUBLIC_URL for OIDC) |
http://localhost:3000 |
No |
JWT_AUDIENCE |
JWT audience claim | bittensor-apps |
No |
JWT_ACCESS_TOKEN_EXPIRY |
Access token TTL fallback (seconds) | 900 |
No |
JWT_REFRESH_TOKEN_EXPIRY |
Refresh token TTL (seconds) | 86400 |
No |
JWT_AUTH_CODE_EXPIRY |
Auth code TTL (seconds) | 30 |
No |
SUBTENSOR_WS_URL |
Subtensor WebSocket URL | wss://entrypoint-finney.opentensor.ai:443 |
No |
SUBTENSOR_BLOCK_TIME |
Block time in seconds | 12 |
No |
TAOSTATS_API_URL |
Taostats API URL (holder fallback + indexed scopes) | https://api.taostats.io |
No |
TAOSTATS_API_KEY |
Taostats API key for delegate/staker scopes |
— | No* |
CHALLENGE_TTL_SECONDS |
Challenge TTL | 120 |
No |
DEVICE_CODE_TTL_SECONDS |
Device code TTL | 300 |
No |
DEVICE_CODE_POLL_INTERVAL |
Minimum poll interval (seconds) | 5 |
No |
VERIFICATION_URI |
Device verification URL | http://localhost:3000/v1/device/verify |
No |
RATE_LIMIT_GLOBAL |
Global rate limit (req/min) | 10 |
No |
RATE_LIMIT_CHALLENGE |
Challenge endpoint rate limit | 5 |
No |
DATABASE_URL |
PostgreSQL connection string | postgresql://localhost:5432/auth_gateway |
Yes |
ADMIN_API_KEY |
Admin API authentication key | — | Yes (prod) |
RUN_MIGRATIONS |
Auto-run migrations on startup | true |
No |
PUBLIC_URL |
Public-facing URL for discovery | http://localhost:3000 |
No |
DEMO_MODE |
Enable demo mode (auto-creates clients, interactive landing page) | false |
No |
* Provide either file paths or base64-encoded keys.
Scopes support both subnet roles and portfolio/delegation checks:
subnet:{netuid}:{role}subnet:{netuid}:holder:{min_alpha}tao:holdertao:holder:{min_tao}delegate:{hotkey}delegate:{hotkey}:{min_tao}staker:{min_tao}
| Scope Family | Description | Verification Source |
|---|---|---|
subnet:*:miner |
Registered miner on subnet | Subtensor keys + zero dividends |
subnet:*:validator |
Validator on subnet | Subtensor keys + non-zero dividends |
subnet:*:owner |
Subnet owner | Subtensor subnetOwner |
subnet:*:holder / subnet:*:holder:{min_alpha} |
Alpha holder (optional minimum) | Subtensor stake info (Taostats fallback) |
tao:holder / tao:holder:{min_tao} |
Free TAO balance holder (optional minimum) | Subtensor system.account |
delegate:{hotkey} / delegate:{hotkey}:{min_tao} |
Delegation to a specific hotkey (optional minimum) | Taostats indexed stake balances |
staker:{min_tao} |
Total staked portfolio threshold | Taostats aggregated stake balances |
subnet:1:miner # Miner on subnet 1
subnet:1:validator # Validator on subnet 1
subnet:18:owner # Owner of subnet 18
subnet:1:holder # Holds alpha tokens on subnet 1
subnet:1:holder:100 # Holds at least 100 alpha on subnet 1
tao:holder # Holds any TAO balance
tao:holder:50 # Holds at least 50 TAO free balance
delegate:5F...abc # Delegates to a specific hotkey
delegate:5F...abc:500 # Delegates at least 500 TAO to that hotkey
staker:1000 # Has at least 1000 TAO total staked
Scopes are verified on-chain during initial authentication and always re-verified on refresh. Access tokens are epoch-aligned — they expire at the next epoch boundary, so refresh frequency is naturally governed by the subnet's tempo.
* TAOSTATS_API_KEY is required when using delegate:* or staker:* scopes.
- PKCE (S256): Required for public clients, prevents authorization code interception
- Timing-safe comparisons: Used for PKCE verification, admin API key checks, and other secret comparisons
- Auth code single-use: JWT-based auth codes are tracked and rejected on replay
- DB-backed stores: Challenges, device codes, and consumed auth codes stored in PostgreSQL (multi-instance safe)
- Refresh token rotation: Old token revoked after new one is stored (safe ordering)
- Admin API key: Required in production (
ADMIN_API_KEYmust be set) - Challenge flow binding: Challenges are bound to their flow (auth, oauth, device) with client/redirect/user-code context, preventing cross-flow reuse
- Origin enforcement: Per-client
allowed_originson token/device endpoints; same-origin enforcement on browser-only endpoints - Redirect URI validation: Whitelisted against registered URIs, HTTPS required in production, fragments and embedded credentials rejected
- Non-root Docker: Production container runs as non-root user
- Rate limiting: Per-IP and per-client rate limiting on all endpoints
- Epoch-aligned access tokens: Access tokens expire at the next epoch boundary, naturally gating scope re-verification frequency
- Subtensor query timeouts: All on-chain queries have 10-second timeouts
src/
├── config.ts # Environment configuration
├── index.ts # Server entrypoint
├── demo.ts # Demo client bootstrap
├── types.ts # TypeScript interfaces
├── styles.ts # Shared CSS
├── crypto/
│ ├── jwt.ts # JWT creation and verification
│ ├── keys.ts # RSA key loading
│ ├── pkce.ts # PKCE S256 challenge/verification
│ ├── challenge.ts # Challenge store (DB-backed)
│ ├── authCodeTracker.ts # Auth code single-use tracking
│ ├── signature.ts # Signature verification (sr25519 + EVM)
│ └── address.ts # Address validation, normalization, sign method detection
├── db/
│ ├── pool.ts # PostgreSQL connection pool
│ ├── migrate.ts # Migration runner
│ ├── clients.ts # Client CRUD (with 60s cache)
│ ├── refreshTokens.ts # Refresh token CRUD
│ ├── challenges.ts # Challenge storage
│ ├── deviceCodes.ts # Device code storage
│ └── consumedAuthCodes.ts # Auth code replay prevention
├── middleware/
│ ├── clientAuth.ts # Client authentication extraction
│ ├── clientRateLimit.ts # Per-client rate limiting
│ └── origin.ts # Origin validation and enforcement
├── routes/
│ ├── index.ts # Route registration
│ ├── auth.ts # Challenge-response flow
│ ├── oauth/
│ │ ├── index.ts # OAuth route registration
│ │ ├── authorize.ts # Authorization page + callback
│ │ ├── token.ts # Token exchange + refresh
│ │ └── shared.ts # Shared utilities (epoch, HTML)
│ ├── device.ts # Device code flow (RFC 8628)
│ ├── admin.ts # Client management API
│ ├── discovery.ts # OIDC discovery document
│ ├── jwks.ts # JWKS endpoint
│ ├── health.ts # Health check
│ └── landing.ts # Landing page (/ route)
├── scopes/
│ ├── index.ts # Scope parsing, validation, verification
│ ├── miner.ts # Miner role (zero dividends)
│ ├── validator.ts # Validator role (non-zero dividends)
│ ├── owner.ts # Subnet owner role
│ ├── holder.ts # Alpha token holder role
│ ├── taoHolder.ts # TAO free balance holder
│ ├── delegate.ts # Delegation to validator (Taostats API)
│ ├── staker.ts # Total staked portfolio (Taostats API)
│ ├── types.ts # Scope handler interface
│ └── signerContext.ts # Signer context (hotkey/coldkey)
├── subtensor/
│ ├── client.ts # Subtensor WebSocket client
│ └── queries.ts # On-chain queries (with timeouts)
├── taostats/
│ └── client.ts # Taostats API client (auth, caching)
├── util/
│ ├── errors.ts # Custom error classes
│ ├── boundedMap.ts # Bounded map with eviction
│ ├── html.ts # HTML escaping utilities
│ └── testnet.ts # Testnet banner helper
└── test/ # Jest test suite
npm test # Run all tests
npx jest --no-cache # Run without cache
npm run typecheck # TypeScript type checking- Create a handler implementing
ScopeHandlerinsrc/scopes/ - Add a regex and parser case in
src/scopes/index.ts - Register the handler in
verifyScopes()and add todescribeScope() - Add the data source query (Subtensor in
src/subtensor/queries.tsor Taostats insrc/taostats/client.ts) - Add tests
docker compose up -d # Start all services
docker compose up -d --build # Rebuild and startThe Dockerfile uses multi-stage builds, runs as non-root, and includes a health check.
Migrations run automatically on startup when RUN_MIGRATIONS=true (default). They can also be run manually:
npm run migrateMigration files are in migrations/ and tracked in the _migrations table.
- Set
NODE_ENV=production - Set a strong
ADMIN_API_KEY - Use HTTPS via a reverse proxy (nginx, Caddy, etc.)
- Set
TRUST_PROXYto match your ingress / proxy topology - Provide RSA keys via
RSA_PRIVATE_KEY_BASE64/RSA_PUBLIC_KEY_BASE64 - Configure
PUBLIC_URLto your external URL - Set
VERIFICATION_URIto your public device verification URL - Set
TAOSTATS_API_KEYif usingdelegate:*orstaker:*scopes - Monitor
/healthendpoint (checks both subtensor and database connectivity)
The gateway implements OAuth2 plus a focused OIDC subset (Discovery + JWKS + Authorization Code + PKCE + ID token). Most OAuth2/OIDC client libraries work with this profile.
- Register a client via the admin API or
npm run setup-examples - Discover endpoints via
/.well-known/openid-configuration - Validate tokens in your resource server using the JWKS at
/.well-known/jwks.json - If using OIDC login request
openid, includenonce, and validateid_token(iss,aud,exp,nonce,at_hash)
Supported OIDC surface:
- Authorization Code flow (
response_type=code) with PKCES256 - Server policy note: when sending
code_challenge, clients must also send explicitcode_challenge_method=S256(no RFC 7636 defaulting) - Discovery document (
/.well-known/openid-configuration) and JWKS (/.well-known/jwks.json) openidscope and ID token issuance from/v1/oauth/tokennoncepassthrough (/v1/oauth/authorize→id_token.nonce)- Core ID token claims used by common libs:
iss,sub,aud,exp,iat,auth_time,at_hash(plus gateway-specifichotkey,coldkey,evm_address,client_id)
Not currently provided:
- UserInfo endpoint
- Session/logout claims/endpoints (
sid, front-channel/back-channel logout) - Authentication context claims (
acr,amr) - Implicit/hybrid response types
If your library expects unsupported claims like sid or acr, configure those checks as optional/disabled.
See the examples/ directory for complete, runnable integration examples:
| Example | Flow | Description |
|---|---|---|
web-app-raw |
Authorization Code + PKCE | Single HTML file, zero dependencies |
web-app-oidc |
OIDC Authorization Code + PKCE | Uses oidc-client-ts library |
cli-device-code |
Device Code (RFC 8628) | Standalone Node.js CLI script |
resource-server |
Token Validation | Express + jose JWKS verification |
Access tokens are RS256 JWTs. Claims differ by wallet type:
Bittensor wallet:
{
"sub": "5GrwvaEF...", // SS58 wallet address
"scope": "subnet:1:validator",
"type": "access",
"jti": "uuid-v4",
"hotkey": "5FHneW46...", // hotkey address (null if signer is coldkey)
"coldkey": "5GrwvaEF...", // coldkey address (always present)
"evm_address": null,
"client_id": "...", // present in OAuth flows
"iss": "https://auth.taostats.io",
"aud": "bittensor-apps",
"exp": 1234567890,
"iat": 1234567800
}Ethereum wallet:
{
"sub": "0x1234...abcd", // EIP-55 checksummed address
"scope": "openid",
"type": "access",
"jti": "uuid-v4",
"hotkey": null,
"coldkey": null,
"evm_address": "0x1234...abcd",
"client_id": "...",
"iss": "https://auth.taostats.io",
"aud": "bittensor-apps",
"exp": 1234567890,
"iat": 1234567800
}For Bittensor wallets, hotkey and coldkey are resolved from the Subtensor chain at authentication time. If the signer is a registered hotkey, hotkey is set to the signing address and coldkey to its owner. If the signer is a coldkey (or unregistered address), hotkey is null and coldkey is the signing address. These are point-in-time snapshots — a hotkey could deregister between token issuance and use. EVM wallets only support the openid scope.
When openid is requested, the token response also includes an OIDC id_token with claims like:
{
"sub": "5GrwvaEF...",
"type": "id",
"client_id": "your-client-id",
"aud": "your-client-id",
"iss": "https://auth.taostats.io",
"auth_time": 1234567800,
"at_hash": "base64url-hash",
"nonce": "optional-nonce-if-supplied",
"hotkey": "5FHneW46...",
"coldkey": "5GrwvaEF...",
"evm_address": null,
"exp": 1234567890,
"iat": 1234567800
}This project is licensed under the Apache License 2.0.
The "Taostats" name and logo are trademarks. See TRADEMARK.md for branding guidelines.
For security issues, see SECURITY.md.