OAuth security hardening: RFC compliance, PKCE binding, rate limiting (#18305)

## Summary

Follow-up to #18267. Hardens the OAuth implementation with security
fixes identified during audit:

**P0 — Critical:**
- Bind authorization codes to `client_id` in context to prevent auth
code injection (RFC 6749 §4.1.3)
- Store PKCE `code_challenge` directly in auth code context instead of a
separate `CodeChallenge` token — cryptographically binds the challenge
to its code
- Enforce `code_verifier` when `code_challenge` was used during
authorization
- Hash authorization codes (SHA-256) before storage to prevent exposure
if DB is compromised
- Add `Cache-Control: no-store` + `Pragma: no-cache` headers on token
responses (RFC 6749 §5.1)
- Add rate limiting on `/oauth/token` endpoint (20 req/min per client
via existing `ThrottlerService`)

**P1 — High:**
- Return HTTP 401 for `invalid_client` errors instead of 400 (RFC 6749
§5.2)
- Verify refresh tokens belong to the presenting client (cross-client
token theft prevention)
- Limit fields exposed by public `findApplicationRegistrationByClientId`
query to only what the frontend needs (`id`, `name`, `logoUrl`,
`websiteUrl`, `oAuthScopes`)
- Require `API_KEYS_AND_WEBHOOKS` permission for
`createApplicationRegistration` mutation

**P2/P3 — Medium/Low:**
- Add error handling and loading states to frontend Authorize page
- Rename redirect URL param from `authorizationCode` to `code` (RFC
standard)
- Add unit tests for `validateRedirectUri` utility (8 test cases)

## Test plan

- [ ] Existing OAuth integration tests updated for all changes (hashed
codes, context-based PKCE, client binding, 401 status codes, cache
headers)
- [ ] New test: auth code rejected when presented by a different client
- [ ] New test: refresh token rejected when presented by a different
client
- [ ] New test: `code_verifier` required when PKCE was used in
authorization
- [ ] New test: `Cache-Control: no-store` header present on responses
- [ ] New unit tests for `validateRedirectUri` (HTTPS, localhost,
fragments, invalid URIs)
- [ ] Verify frontend authorize page shows errors gracefully


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-02 12:21:26 +01:00
committed by GitHub
parent d021f7e369
commit 1a8be234de
24 changed files with 1697 additions and 168 deletions
@@ -0,0 +1,46 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddWorkspaceIdToApplicationRegistration1772267875869
implements MigrationInterface
{
name = 'AddWorkspaceIdToApplicationRegistration1772267875869';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "workspaceId" uuid`,
);
// Delete any orphaned registrations that can't be assigned a workspace
await queryRunner.query(`
DELETE FROM "core"."applicationRegistration"
WHERE "workspaceId" IS NULL
`);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" SET NOT NULL`,
);
await queryRunner.query(`
CREATE INDEX "IDX_APPLICATION_REGISTRATION_WORKSPACE_ID"
ON "core"."applicationRegistration" ("workspaceId")
`);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_94ab20372e448d45088357f884e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_94ab20372e448d45088357f884e"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APPLICATION_REGISTRATION_WORKSPACE_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "workspaceId"`,
);
}
}