Skip to content

Tech Story: OAuth 2.0 Client Credentials grant (M2M auth for Station-Bot) #110

@GitAddRemote

Description

@GitAddRemote

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)

  1. Station-Bot has a client_id (like a username for apps) and client_secret (like a password for apps) stored in its environment variables.
  2. On startup, it sends a POST request to https://api.drdnt.org/auth/token with those credentials.
  3. Station verifies the credentials, then returns a short-lived JWT access token (e.g. 1 hour).
  4. Station-Bot attaches that token to every API call: Authorization: Bearer <token>.
  5. Station validates the token on every request — same as user tokens, but the payload identifies a client not a user.
  6. 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

  • oauth_clients table created via migration (with working down())
  • POST /auth/token with grant_type=client_credentials returns a signed JWT
  • Client secret stored as bcrypt hash — plaintext never persisted
  • Client JWT payload includes sub (clientId), type: 'client', scopes, jti
  • JTI stored in Redis for revocation support
  • ClientAuthGuard and ScopesGuard implemented and usable on any endpoint
  • Admin POST /oauth-clients endpoint for client registration (guarded, not public)
  • Unit + E2E tests pass
  • pnpm test and pnpm test:e2e pass

Dependencies

Metadata

Metadata

Assignees

Labels

apiPublic/internal API endpointsbackendBackend services and logicsecuritySecurity, auth, and permissionstech-storyTechnical implementation story

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions