Tech Story
As a security engineer, I want Station to implement the OAuth 2.0 Client Credentials grant so that machine clients like Station-Bot can authenticate against the Station API using industry-standard M2M (machine-to-machine) auth without impersonating a user.
ELI5 Context
Why can't Station-Bot just use a username and password?
Station-Bot is not a person — it's a program running 24/7. Having it log in with a username/password like a user creates problems: whose account does it use? What happens if that user is deleted? What permissions does it have? The Client Credentials flow is designed specifically for this: instead of a person, you register an application with a client_id and client_secret, and that application gets its own identity and permissions.
How does Client Credentials work? (The full flow)
- Station-Bot has a
client_id (like a username for apps) and client_secret (like a password for apps) stored in its environment variables.
- On startup, it sends a POST request to
https://api.drdnt.org/auth/token with those credentials.
- Station verifies the credentials, then returns a short-lived JWT access token (e.g. 1 hour).
- Station-Bot attaches that token to every API call:
Authorization: Bearer <token>.
- Station validates the token on every request — same as user tokens, but the payload identifies a
client not a user.
- When the token is about to expire, Station-Bot requests a new one automatically.
Why is this more secure than a static API key?
- Tokens expire (a stolen token has limited lifetime)
- Token issuance is audited (you can see when clients authenticate)
- Scopes can limit what each client can do
- Clients can be revoked instantly (delete the client record → all future token requests fail)
- This is exactly what AWS, Stripe, Twilio, and GitHub Apps use for service-to-service auth
What is a "scope"?
A scope is a permission label on a token. A token with scope inventory:read can only read inventory — even if it somehow gets misused, it can't write data. We'll start with a simple bot:api scope for Station-Bot.
Technical Elaboration
New entity: backend/src/oauth-clients/oauth-client.entity.ts
@Entity('oauth_clients')
export class OauthClient {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
clientId: string; // e.g. 'station-bot'
@Column()
clientSecretHash: string; // bcrypt hash — never store plaintext
@Column('simple-array')
scopes: string[]; // e.g. ['bot:api']
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
}
Migration
pnpm migration:create src/migrations/CreateOauthClientsTable
- Creates
oauth_clients table with columns above
- Includes
down() method that drops the table
New module: backend/src/oauth-clients/
oauth-clients.module.ts
oauth-clients.service.ts: findByClientId(clientId), validateSecret(client, secret) (bcrypt compare), register(clientId, secret, scopes) (admin use only)
oauth-clients.controller.ts: Admin-only POST /oauth-clients endpoint to register new clients (protected by a strong guard — internal/admin use only, not public)
New endpoint: POST /auth/token (in auth.controller.ts)
Implements RFC 6749 Client Credentials grant:
Request body (application/x-www-form-urlencoded):
grant_type=client_credentials
client_id=station-bot
client_secret=<secret>
scope=bot:api
Response:
{ "access_token": "<jwt>", "token_type": "Bearer", "expires_in": 3600 }
- Look up client by
client_id
- Verify
client_secret against stored bcrypt hash
- If invalid → 401
- Generate JWT with payload:
{ sub: clientId, type: 'client', scopes: ['bot:api'], jti: uuid() }
- Store JTI in Redis with TTL matching token expiry (for blacklist/revocation)
- Return token response (no cookie — M2M clients use the Bearer header directly)
New Passport strategy: backend/src/auth/strategies/client-credentials.strategy.ts
- Validates tokens where
payload.type === 'client'
- Checks JTI blacklist in Redis
- Attaches client record to request (so downstream guards can check scopes)
New guard: backend/src/auth/guards/client-auth.guard.ts
- Accepts requests with a valid client JWT
- Can be used alongside
JwtAuthGuard on endpoints that accept both user and bot tokens
Scopes guard: backend/src/auth/guards/scopes.guard.ts
- Decorator
@RequireScopes('inventory:read') + guard that checks request.client.scopes
Tests
- Unit:
OauthClientsService.validateSecret — correct/incorrect secret
- Unit:
/auth/token — valid credentials → token; invalid → 401; inactive client → 401
- E2E: full flow — register client → POST /auth/token → use token on protected endpoint → 200; use expired/revoked token → 401
Definition of Done
Dependencies
Tech Story
As a security engineer, I want Station to implement the OAuth 2.0 Client Credentials grant so that machine clients like Station-Bot can authenticate against the Station API using industry-standard M2M (machine-to-machine) auth without impersonating a user.
ELI5 Context
Why can't Station-Bot just use a username and password?
Station-Bot is not a person — it's a program running 24/7. Having it log in with a username/password like a user creates problems: whose account does it use? What happens if that user is deleted? What permissions does it have? The Client Credentials flow is designed specifically for this: instead of a person, you register an application with a
client_idandclient_secret, and that application gets its own identity and permissions.How does Client Credentials work? (The full flow)
client_id(like a username for apps) andclient_secret(like a password for apps) stored in its environment variables.https://api.drdnt.org/auth/tokenwith those credentials.Authorization: Bearer <token>.clientnot auser.Why is this more secure than a static API key?
What is a "scope"?
A scope is a permission label on a token. A token with scope
inventory:readcan only read inventory — even if it somehow gets misused, it can't write data. We'll start with a simplebot:apiscope for Station-Bot.Technical Elaboration
New entity:
backend/src/oauth-clients/oauth-client.entity.tsMigration
pnpm migration:create src/migrations/CreateOauthClientsTableoauth_clientstable with columns abovedown()method that drops the tableNew module:
backend/src/oauth-clients/oauth-clients.module.tsoauth-clients.service.ts:findByClientId(clientId),validateSecret(client, secret)(bcrypt compare),register(clientId, secret, scopes)(admin use only)oauth-clients.controller.ts: Admin-onlyPOST /oauth-clientsendpoint to register new clients (protected by a strong guard — internal/admin use only, not public)New endpoint:
POST /auth/token(inauth.controller.ts)Implements RFC 6749 Client Credentials grant:
client_idclient_secretagainst stored bcrypt hash{ sub: clientId, type: 'client', scopes: ['bot:api'], jti: uuid() }New Passport strategy:
backend/src/auth/strategies/client-credentials.strategy.tspayload.type === 'client'New guard:
backend/src/auth/guards/client-auth.guard.tsJwtAuthGuardon endpoints that accept both user and bot tokensScopes guard:
backend/src/auth/guards/scopes.guard.ts@RequireScopes('inventory:read')+ guard that checksrequest.client.scopesTests
OauthClientsService.validateSecret— correct/incorrect secret/auth/token— valid credentials → token; invalid → 401; inactive client → 401Definition of Done
oauth_clientstable created via migration (with workingdown())POST /auth/tokenwithgrant_type=client_credentialsreturns a signed JWTsub(clientId),type: 'client',scopes,jtiClientAuthGuardandScopesGuardimplemented and usable on any endpointPOST /oauth-clientsendpoint for client registration (guarded, not public)pnpm testandpnpm test:e2epassDependencies