feat: OAuth 2.0 Client Credentials grant for M2M auth#150
Conversation
Implements the Client Credentials flow so machine clients like
Station-Bot can authenticate against the Station API without
impersonating a user.
New module: oauth-clients
- OauthClient entity with clientId, bcrypt-hashed secret, scopes,
isActive, createdAt (oauth_clients table)
- OauthClientsService: register, findByClientId, validateSecret,
validateClient (constant-time dummy compare prevents enumeration)
- OauthClientsController: admin-only POST /oauth-clients guarded by
InternalApiKeyGuard (x-internal-api-key header or ApiKey scheme)
- Migration 1777647814618-CreateOauthClientsTable (with down())
New auth endpoint: POST /auth/token
- Accepts grant_type=client_credentials + client_id + client_secret
- Returns { access_token, token_type: "Bearer", expires_in: 3600 }
- JWT payload: { sub: clientId, type: "client", scopes, jti }
- JTI stored in Redis (client-token:{jti}) for revocation support
New guards and decorator
- ClientAuthGuard: validates client JWTs from Authorization: Bearer
header, checks JTI blacklist, attaches payload to request.clientToken
- ScopesGuard: checks request.clientToken.scopes against @RequireScopes
- @RequireScopes(...scopes) decorator for endpoint-level scope enforcement
Environment
- INTERNAL_API_KEY added to env validation (required in production,
optional in dev/test, min 32 chars)
- .env.example updated with documentation
Tests
- Unit: OauthClientsService — register, validateSecret, validateClient
(correct/wrong/missing/inactive cases)
- E2E: full flow — register, token issuance, payload validation;
invalid secret, unknown client, inactive client, wrong grant_type,
unauthenticated admin endpoint
There was a problem hiding this comment.
Pull request overview
Adds OAuth 2.0 Client Credentials (M2M) authentication to the backend, including client registration, token issuance, and initial guard/decorator primitives for client-token authorization.
Changes:
- Introduces
oauth_clientspersistence (entity + migration) and an internal/admin client registration endpoint (POST /oauth-clients). - Adds
POST /auth/tokento issue client JWTs (type=client, scopes, jti) and records issued JTIs in Redis. - Adds
ClientAuthGuard,ScopesGuard, and@RequireScopes()plus unit/e2e test coverage for the new flow.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/test/oauth-client-credentials.e2e-spec.ts | E2E coverage for token issuance and internal endpoint auth requirement. |
| backend/src/modules/oauth-clients/oauth-clients.service.ts | Core client registration + credential validation logic (bcrypt). |
| backend/src/modules/oauth-clients/oauth-clients.service.spec.ts | Unit tests for registration + validation behaviors. |
| backend/src/modules/oauth-clients/oauth-clients.module.ts | Wires oauth-clients controller/service into Nest module system. |
| backend/src/modules/oauth-clients/oauth-clients.controller.ts | Admin/internal endpoint to register OAuth clients. |
| backend/src/modules/oauth-clients/oauth-client.entity.ts | TypeORM entity for oauth_clients. |
| backend/src/modules/oauth-clients/internal-api-key.guard.ts | Guard enforcing INTERNAL_API_KEY for internal admin endpoint. |
| backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts | DTO/validation for client registration requests. |
| backend/src/modules/auth/interfaces/client-jwt-payload.interface.ts | Defines client JWT payload shape. |
| backend/src/modules/auth/guards/scopes.guard.ts | Enforces required scopes from @RequireScopes(). |
| backend/src/modules/auth/guards/client-auth.guard.ts | Verifies Bearer client JWTs + checks revocation blacklist. |
| backend/src/modules/auth/dto/token-request.dto.ts | DTO for the /auth/token request. |
| backend/src/modules/auth/decorators/require-scopes.decorator.ts | Metadata decorator to declare required scopes. |
| backend/src/modules/auth/auth.service.ts | Issues client tokens and stores token JTI metadata in Redis. |
| backend/src/modules/auth/auth.module.ts | Imports oauth-clients module and adjusts exports. |
| backend/src/modules/auth/auth.controller.ts | Adds POST /auth/token client-credentials endpoint. |
| backend/src/modules/auth/auth.controller.spec.ts | Updates controller test module wiring for new dependency. |
| backend/src/migrations/1777647814618-CreateOauthClientsTable.ts | Migration creating oauth_clients table. |
| backend/src/config/env.validation.ts | Adds INTERNAL_API_KEY validation (required in production). |
| backend/src/app.module.ts | Registers OauthClientsModule in the app. |
| backend/.env.example | Documents INTERNAL_API_KEY env var for M2M admin endpoint. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…110) 1. Throttle POST /auth/token (medium) Add @Throttle on the token endpoint matching the pattern used by login and forgot-password. Configurable via AUTH_TOKEN_THROTTLE_TTL_MS and AUTH_TOKEN_THROTTLE_LIMIT (defaults: 60s / 10 requests). 2. Accept Authorization: Basic and form-urlencoded (medium) RFC 6749 §2.3.1: if an Authorization: Basic header is present, decode it and use its client_id:client_secret instead of body params. The body DTO fields are no longer required when Basic is supplied. Allows standard OAuth tooling to authenticate without special configuration. 3. Intersect requested scope with registered scopes (medium) The scope parameter was accepted but ignored — issueClientToken() always emitted client.scopes wholesale. The controller now intersects dto.scope (space-separated per RFC 6749) with client.scopes and passes only the granted subset to issueClientToken(). Requesting a scope not in the registered set returns 401. Omitting scope grants the full registered set per RFC 6749 §4.4.2. 4. Enforce MinLength(32) on client secret at registration (low) RegisterOauthClientDto only checked IsString/IsNotEmpty, so short secrets like "abc" were accepted. Added @minlength(32) to match the documented minimum and block weak secrets at the validation layer. E2E tests: added cases for Basic auth (8), scope subset minting (9), out-of-range scope rejection (10), and weak-secret registration (11).
- Make client_id and client_secret @IsOptional() in TokenRequestDto so ValidationPipe does not reject requests that supply credentials via Authorization: Basic header with only grant_type in the body - Add missing-credentials guard in controller after Basic header parsing so requests lacking both body params and a Basic header still get 401 - Remove e2e test for weak-secret 400 (InternalApiKeyGuard fires first in CI where INTERNAL_API_KEY is unset, returning 401 before DTO validation; MinLength(32) coverage lives in unit tests)
There was a problem hiding this comment.
Pull request overview
Adds machine-to-machine authentication to the backend via an OAuth 2.0 Client Credentials-style flow, including client registration and JWT-based client token validation primitives.
Changes:
- Introduces
POST /auth/tokento mint client JWTs (including Basic auth support + scope selection). - Adds
oauth_clientspersistence (entity + migration) and an internal-key-guardedPOST /oauth-clientsregistration endpoint. - Adds client-token guard/decorator building blocks (
ClientAuthGuard,ScopesGuard,@RequireScopes) plus unit/e2e coverage.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/test/oauth-client-credentials.e2e-spec.ts | E2E coverage for token issuance, invalid credentials, Basic auth, and scope behavior. |
| backend/src/modules/oauth-clients/oauth-clients.service.ts | Service to register clients, hash secrets, and validate client credentials. |
| backend/src/modules/oauth-clients/oauth-clients.service.spec.ts | Unit tests for registration and credential validation logic. |
| backend/src/modules/oauth-clients/oauth-clients.module.ts | Declares OAuth clients module wiring (TypeORM + controller/service). |
| backend/src/modules/oauth-clients/oauth-clients.controller.ts | Internal/admin endpoint to register OAuth clients. |
| backend/src/modules/oauth-clients/oauth-client.entity.ts | TypeORM entity for oauth_clients. |
| backend/src/modules/oauth-clients/internal-api-key.guard.ts | Guard protecting internal client-registration endpoint via INTERNAL_API_KEY. |
| backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts | DTO validation for client registration payload. |
| backend/src/modules/auth/interfaces/client-jwt-payload.interface.ts | Defines JWT payload shape for client tokens. |
| backend/src/modules/auth/guards/scopes.guard.ts | Guard enforcing required scopes from @RequireScopes. |
| backend/src/modules/auth/guards/client-auth.guard.ts | Guard validating client JWTs and checking JTI blacklist. |
| backend/src/modules/auth/dto/token-request.dto.ts | DTO validation for /auth/token request body. |
| backend/src/modules/auth/decorators/require-scopes.decorator.ts | Decorator to attach required scopes metadata to handlers. |
| backend/src/modules/auth/auth.service.ts | Adds issueClientToken() with JTI tracking in Redis. |
| backend/src/modules/auth/auth.module.ts | Wires OAuth clients module into auth; adjusts exports. |
| backend/src/modules/auth/auth.controller.ts | Implements POST /auth/token endpoint with throttling and scope selection. |
| backend/src/modules/auth/auth.controller.spec.ts | Updates controller test DI setup to include OauthClientsService. |
| backend/src/migrations/1777647814618-CreateOauthClientsTable.ts | Migration creating the oauth_clients table. |
| backend/src/config/env.validation.ts | Adds Joi validation for INTERNAL_API_KEY (required in production). |
| backend/src/app.module.ts | Registers OauthClientsModule in the application module. |
| backend/.env.example | Documents new env vars for auth token throttling + internal API key. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- oauth-clients.service: replace invalid dummy bcrypt hash with a real precomputed hash so unknown-client requests never 500; always run bcrypt compare for unknown/inactive/wrong-secret paths to close timing leaks; unify all auth failures to a single generic 401 message to prevent client-id enumeration - internal-api-key.guard: normalize header to string (Express can return string[]), use crypto.timingSafeEqual to prevent timing side-channels - oauth-clients.module: register and export InternalApiKeyGuard as a provider so ConfigService DI is resolved correctly - auth.module: register ClientAuthGuard and ScopesGuard as providers and export them; remove JwtModule export (no longer needed externally) - register-oauth-client.dto: add @maxlength(128) to clientSecret; add @matches no-comma validator to each scope string to prevent simple-array storage corruption in PostgreSQL - auth.controller: reject token requests where any requested scope is not in the client's registered set instead of silently dropping unknown scopes - auth.service: fix client-token Redis key to include clientId for per-client enumerability; remove misleading comment - e2e spec: fix flaky exp-iat assertion by asserting expires_in from the response body instead; update test 9 description to reflect strict subset validation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Both InternalApiKeyGuard and the /auth/token controller read the Authorization header via Express, which can return string | string[] when duplicate headers are present. Calling .replace() or .startsWith() directly on a string[] throws and turns an auth failure into a 500. - internal-api-key.guard: extract normalize() helper; apply it to both x-internal-api-key and authorization headers before any string ops - auth.controller: widen rawAuthHeader param to string | string[], normalize to a single string before the Basic prefix check
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Agent-Logs-Url: https://github.com/GitAddRemote/station/sessions/005b4e97-96bc-4999-a9fe-50b89de1a448 Co-authored-by: GitAddRemote <55011225+GitAddRemote@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Make Basic auth scheme detection case-insensitive (RFC 7617 §2) - Fix timingSafeEqual pre-check to compare byte lengths via Buffer, not string code-point lengths; wrap call in try/catch for safety - Register 'internal-api-key' ApiKey scheme in Swagger DocumentBuilder to match @apisecurity('internal-api-key') on OauthClientsController - Wrap repo.save() in try/catch; map Postgres 23505 unique violation to ConflictException to close the TOCTOU race in register()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Fix 23505 unique-violation detection to check both err.code and err.driverError.code, matching the TypeORM QueryFailedError structure and consistent with UsersService.create() error handling pattern - Wrap e2e test 9 (multiscope client) in try/finally so the temporary client row is always cleaned up even when an assertion fails mid-test - Add e2e test for application/x-www-form-urlencoded request body to prevent regressions in the OAuth-spec form-encoded request format
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Make Bearer scheme extraction in ClientAuthGuard case-insensitive (RFC 6750 §2.1) using match(/^bearer /i) and index-based slice - Remove undocumented Authorization: ApiKey fallback from InternalApiKeyGuard; restrict to x-internal-api-key header only, matching the documented Swagger security scheme - Clarify .env.example comment: blank/unset INTERNAL_API_KEY disables the /oauth-clients endpoint (guard always 401s), not just optional - Treat whitespace-only scope values as absent rather than minting a zero-scope token (scope=" " now falls back to client's full scopes) - Add e2e happy-path test for POST /oauth-clients with a valid INTERNAL_API_KEY, with try/finally cleanup of the created client
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Restore process.env.INTERNAL_API_KEY in afterAll to prevent leaking into other e2e suites sharing the same Jest worker process - Trim extracted base64 segment in Basic auth parsing to tolerate extra whitespace in the header value; wrap Buffer.from decode in try/catch - Trim extracted Bearer token in ClientAuthGuard to tolerate extra whitespace between the scheme and token
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Validate required JWT claims (sub, jti as non-empty strings, scopes as array) before the blacklist check in ClientAuthGuard so a token missing jti cannot bypass revocation via a blacklist:undefined lookup - Remove dead try/catch around Buffer.from(..., 'base64') — Node's base64 decoder never throws; garbled input is caught by the colon check that follows
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
All three conflicts were comment-only wording differences. Kept the main branch versions in each case (the reviewed/fixed versions).
Closes #110
Summary
POST /auth/token— OAuth 2.0 Client Credentials endpoint. Acceptsgrant_type=client_credentials+client_id+client_secret, returns{ access_token, token_type: "Bearer", expires_in: 3600 }. Client JWT payload includessub(clientId),type: "client",scopes, andjti.oauth_clientstable — new entity and migration. Secrets stored as bcrypt hashes (cost 12); plaintext never persisted. Constant-time dummy compare on unknown clients prevents timing-based enumeration.POST /oauth-clients— admin-only registration endpoint, guarded byInternalApiKeyGuard(x-internal-api-keyheader).INTERNAL_API_KEYenv var is required in production (min 32 chars).ClientAuthGuard— validatesAuthorization: Bearerclient JWTs, checks JTI blacklist, attachesrequest.clientTokenfor downstream guards.ScopesGuard+@RequireScopes()— decorator/guard pair for endpoint-level scope enforcement on any route.client-token:{jti}) to support future revocation viablacklistAccessToken.Test plan
OauthClientsService— register (hashes secret, conflict),validateSecret(correct/wrong),validateClient(valid, not found, inactive, wrong secret)grant_type→ 400; token payload structure (sub, type, scopes, jti, exp-iat=3600); unauthenticatedPOST /oauth-clients→ 401pnpm typecheckpassespnpm test— 282 unit tests passpnpm test:e2e— 97 e2e tests pass (4 skipped, PostgreSQL tool tests in CI)Dependencies
Depends on #109 (Redis auth patterns — JTI, Redis helpers,
blacklistAccessToken)