Skip to content

feat: OAuth 2.0 Client Credentials grant for M2M auth#150

Merged
GitAddRemote merged 13 commits into
mainfrom
feature/ISSUE-110
May 2, 2026
Merged

feat: OAuth 2.0 Client Credentials grant for M2M auth#150
GitAddRemote merged 13 commits into
mainfrom
feature/ISSUE-110

Conversation

@GitAddRemote
Copy link
Copy Markdown
Owner

Closes #110

Summary

  • POST /auth/token — OAuth 2.0 Client Credentials endpoint. Accepts grant_type=client_credentials + client_id + client_secret, returns { access_token, token_type: "Bearer", expires_in: 3600 }. Client JWT payload includes sub (clientId), type: "client", scopes, and jti.
  • oauth_clients table — 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 by InternalApiKeyGuard (x-internal-api-key header). INTERNAL_API_KEY env var is required in production (min 32 chars).
  • ClientAuthGuard — validates Authorization: Bearer client JWTs, checks JTI blacklist, attaches request.clientToken for downstream guards.
  • ScopesGuard + @RequireScopes() — decorator/guard pair for endpoint-level scope enforcement on any route.
  • JTI written to Redis at issuance (client-token:{jti}) to support future revocation via blacklistAccessToken.

Test plan

  • Unit: OauthClientsService — register (hashes secret, conflict), validateSecret (correct/wrong), validateClient (valid, not found, inactive, wrong secret)
  • E2E: valid credentials → token; wrong secret → 401; unknown client → 401; inactive client → 401; wrong grant_type → 400; token payload structure (sub, type, scopes, jti, exp-iat=3600); unauthenticated POST /oauth-clients → 401
  • pnpm typecheck passes
  • pnpm test — 282 unit tests pass
  • pnpm test:e2e — 97 e2e tests pass (4 skipped, PostgreSQL tool tests in CI)

Dependencies

Depends on #109 (Redis auth patterns — JTI, Redis helpers, blacklistAccessToken)

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
Copilot AI review requested due to automatic review settings May 1, 2026 15:59
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_clients persistence (entity + migration) and an internal/admin client registration endpoint (POST /oauth-clients).
  • Adds POST /auth/token to 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.

Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts Outdated
Comment thread backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts Outdated
Comment thread backend/src/modules/auth/guards/scopes.guard.ts
Comment thread backend/test/oauth-client-credentials.e2e-spec.ts Outdated
Comment thread backend/src/modules/oauth-clients/oauth-clients.module.ts
Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
Comment thread backend/src/modules/auth/guards/client-auth.guard.ts
Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts Outdated
…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)
Copilot AI review requested due to automatic review settings May 1, 2026 16:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/token to mint client JWTs (including Basic auth support + scope selection).
  • Adds oauth_clients persistence (entity + migration) and an internal-key-guarded POST /oauth-clients registration 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.

Comment thread backend/src/modules/auth/auth.module.ts
Comment thread backend/src/modules/auth/auth.service.ts Outdated
Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts Outdated
Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts Outdated
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
Comment thread backend/src/modules/oauth-clients/oauth-clients.module.ts Outdated
Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts Outdated
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/migrations/1777647814618-CreateOauthClientsTable.ts
Comment thread backend/src/modules/oauth-clients/dto/register-oauth-client.dto.ts Outdated
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
Comment thread backend/test/oauth-client-credentials.e2e-spec.ts Outdated
Copilot AI and others added 2 commits May 1, 2026 19:26
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 1, 2026 19:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts Outdated
Comment thread backend/src/modules/oauth-clients/oauth-clients.controller.ts
Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 1, 2026 19:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts Outdated
- 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()
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/oauth-clients/oauth-clients.service.ts
Comment thread backend/test/oauth-client-credentials.e2e-spec.ts
Comment thread backend/test/oauth-client-credentials.e2e-spec.ts Outdated
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/auth/guards/client-auth.guard.ts Outdated
Comment thread backend/test/oauth-client-credentials.e2e-spec.ts
Comment thread backend/src/modules/oauth-clients/internal-api-key.guard.ts Outdated
Comment thread backend/.env.example Outdated
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/test/oauth-client-credentials.e2e-spec.ts Outdated
Comment thread backend/src/modules/auth/auth.controller.ts
Comment thread backend/src/modules/auth/guards/client-auth.guard.ts Outdated
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/src/modules/auth/guards/client-auth.guard.ts
Comment thread backend/src/modules/auth/auth.controller.ts Outdated
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@GitAddRemote GitAddRemote merged commit 58670e4 into main May 2, 2026
13 checks passed
@GitAddRemote GitAddRemote deleted the feature/ISSUE-110 branch May 2, 2026 05:48
GitAddRemote added a commit that referenced this pull request May 2, 2026
All three conflicts were comment-only wording differences. Kept the
main branch versions in each case (the reviewed/fixed versions).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants