@rudderjs/passport 0.1.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/Passport.d.ts +93 -0
  2. package/dist/Passport.d.ts.map +1 -1
  3. package/dist/Passport.js +147 -0
  4. package/dist/Passport.js.map +1 -1
  5. package/dist/client-secret.d.ts +12 -0
  6. package/dist/client-secret.d.ts.map +1 -0
  7. package/dist/client-secret.js +63 -0
  8. package/dist/client-secret.js.map +1 -0
  9. package/dist/commands/client.d.ts +21 -0
  10. package/dist/commands/client.d.ts.map +1 -1
  11. package/dist/commands/client.js +27 -2
  12. package/dist/commands/client.js.map +1 -1
  13. package/dist/commands/keys.d.ts +28 -4
  14. package/dist/commands/keys.d.ts.map +1 -1
  15. package/dist/commands/keys.js +34 -4
  16. package/dist/commands/keys.js.map +1 -1
  17. package/dist/commands/purge.d.ts +6 -1
  18. package/dist/commands/purge.d.ts.map +1 -1
  19. package/dist/commands/purge.js +15 -31
  20. package/dist/commands/purge.js.map +1 -1
  21. package/dist/device-code-secret.d.ts +28 -0
  22. package/dist/device-code-secret.d.ts.map +1 -0
  23. package/dist/device-code-secret.js +31 -0
  24. package/dist/device-code-secret.js.map +1 -0
  25. package/dist/grants/authorization-code.d.ts +23 -0
  26. package/dist/grants/authorization-code.d.ts.map +1 -1
  27. package/dist/grants/authorization-code.js +126 -15
  28. package/dist/grants/authorization-code.js.map +1 -1
  29. package/dist/grants/client-credentials.d.ts.map +1 -1
  30. package/dist/grants/client-credentials.js +13 -5
  31. package/dist/grants/client-credentials.js.map +1 -1
  32. package/dist/grants/device-code.d.ts +10 -1
  33. package/dist/grants/device-code.d.ts.map +1 -1
  34. package/dist/grants/device-code.js +41 -10
  35. package/dist/grants/device-code.js.map +1 -1
  36. package/dist/grants/index.d.ts +1 -1
  37. package/dist/grants/index.d.ts.map +1 -1
  38. package/dist/grants/index.js +1 -1
  39. package/dist/grants/index.js.map +1 -1
  40. package/dist/grants/issue-tokens.d.ts +9 -0
  41. package/dist/grants/issue-tokens.d.ts.map +1 -1
  42. package/dist/grants/issue-tokens.js +39 -5
  43. package/dist/grants/issue-tokens.js.map +1 -1
  44. package/dist/grants/refresh-token.d.ts.map +1 -1
  45. package/dist/grants/refresh-token.js +64 -9
  46. package/dist/grants/refresh-token.js.map +1 -1
  47. package/dist/grants/safe-compare.d.ts +19 -0
  48. package/dist/grants/safe-compare.d.ts.map +1 -0
  49. package/dist/grants/safe-compare.js +28 -0
  50. package/dist/grants/safe-compare.js.map +1 -0
  51. package/dist/index.d.ts +27 -6
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +122 -67
  54. package/dist/index.js.map +1 -1
  55. package/dist/middleware/bearer.d.ts.map +1 -1
  56. package/dist/middleware/bearer.js +36 -6
  57. package/dist/middleware/bearer.js.map +1 -1
  58. package/dist/middleware/scope.d.ts +12 -2
  59. package/dist/middleware/scope.d.ts.map +1 -1
  60. package/dist/middleware/scope.js +46 -2
  61. package/dist/middleware/scope.js.map +1 -1
  62. package/dist/models/AccessToken.d.ts +32 -0
  63. package/dist/models/AccessToken.d.ts.map +1 -1
  64. package/dist/models/AccessToken.js +63 -3
  65. package/dist/models/AccessToken.js.map +1 -1
  66. package/dist/models/AuthCode.d.ts +16 -0
  67. package/dist/models/AuthCode.d.ts.map +1 -1
  68. package/dist/models/AuthCode.js +17 -1
  69. package/dist/models/AuthCode.js.map +1 -1
  70. package/dist/models/DeviceCode.d.ts +12 -2
  71. package/dist/models/DeviceCode.d.ts.map +1 -1
  72. package/dist/models/DeviceCode.js +7 -1
  73. package/dist/models/DeviceCode.js.map +1 -1
  74. package/dist/models/OAuthClient.d.ts +4 -0
  75. package/dist/models/OAuthClient.d.ts.map +1 -1
  76. package/dist/models/OAuthClient.js +13 -1
  77. package/dist/models/OAuthClient.js.map +1 -1
  78. package/dist/models/RefreshToken.d.ts +11 -0
  79. package/dist/models/RefreshToken.d.ts.map +1 -1
  80. package/dist/models/RefreshToken.js +12 -2
  81. package/dist/models/RefreshToken.js.map +1 -1
  82. package/dist/models/helpers.d.ts +6 -0
  83. package/dist/models/helpers.d.ts.map +1 -1
  84. package/dist/models/helpers.js +15 -2
  85. package/dist/models/helpers.js.map +1 -1
  86. package/dist/opaque-token.d.ts +32 -0
  87. package/dist/opaque-token.d.ts.map +1 -0
  88. package/dist/opaque-token.js +38 -0
  89. package/dist/opaque-token.js.map +1 -0
  90. package/dist/personal-access-tokens.d.ts.map +1 -1
  91. package/dist/personal-access-tokens.js +48 -10
  92. package/dist/personal-access-tokens.js.map +1 -1
  93. package/dist/routes.d.ts +149 -0
  94. package/dist/routes.d.ts.map +1 -1
  95. package/dist/routes.js +279 -41
  96. package/dist/routes.js.map +1 -1
  97. package/dist/token.d.ts +80 -4
  98. package/dist/token.d.ts.map +1 -1
  99. package/dist/token.js +97 -13
  100. package/dist/token.js.map +1 -1
  101. package/package.json +5 -5
  102. package/schema/passport.prisma +29 -9
package/dist/routes.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { MiddlewareHandler } from '@rudderjs/contracts';
1
2
  type RouteHandler = (req: any, res: any) => Promise<any> | any;
2
3
  interface Router {
3
4
  get(path: string, handler: RouteHandler, ...middleware: any[]): void;
@@ -13,6 +14,108 @@ export interface PassportRouteOptions {
13
14
  verificationUri?: string;
14
15
  /** Route groups to skip when registering. */
15
16
  except?: PassportRouteGroup[];
17
+ /**
18
+ * Middleware applied to `POST /oauth/token`. The token endpoint is the
19
+ * canonical brute-force target for client_secret guessing — every
20
+ * production app SHOULD mount a per-route rate limiter here.
21
+ *
22
+ * Recommended setup:
23
+ *
24
+ * ```ts
25
+ * import { RateLimit } from '@rudderjs/middleware'
26
+ * import { registerPassportRoutes } from '@rudderjs/passport'
27
+ *
28
+ * registerPassportRoutes(router, {
29
+ * tokenMiddleware: [
30
+ * RateLimit.perMinute(10).by((req) => `${req.ip}:${req.body?.client_id}`),
31
+ * ],
32
+ * })
33
+ * ```
34
+ *
35
+ * The composite key (`ip + client_id`) prevents one noisy client from
36
+ * exhausting the budget for legitimate co-tenants behind a shared NAT,
37
+ * and prevents a single IP from churning through every client_id in the
38
+ * registry. RateLimit also requires a cache provider to be registered —
39
+ * see `@rudderjs/cache`. Without one the middleware silently passes
40
+ * through.
41
+ *
42
+ * Accepts a single handler or an array. Empty / omitted means no
43
+ * additional middleware is applied (the same as before this option
44
+ * existed).
45
+ */
46
+ tokenMiddleware?: MiddlewareHandler | MiddlewareHandler[];
47
+ /**
48
+ * Middleware applied to the consent endpoints — `GET/POST/DELETE
49
+ * /oauth/authorize` and `DELETE /oauth/tokens/:id`. POST /oauth/authorize
50
+ * is the canonical CSRF target (an attacker page that auto-submits a
51
+ * hidden form would mint authorization codes for the victim's logged-in
52
+ * session).
53
+ *
54
+ * Most apps should NOT use this option. The recommended pattern is to
55
+ * mount CSRF on the entire web group from `bootstrap/app.ts`:
56
+ *
57
+ * ```ts
58
+ * .withMiddleware((m) => m.web(CsrfMiddleware()))
59
+ * ```
60
+ *
61
+ * which automatically covers `/oauth/authorize` along with every other
62
+ * state-changing web route. `authorizeMiddleware` is the per-route
63
+ * fallback for apps that do NOT mount CSRF at the group level:
64
+ *
65
+ * ```ts
66
+ * import { CsrfMiddleware } from '@rudderjs/middleware'
67
+ * import { registerPassportWebRoutes } from '@rudderjs/passport'
68
+ *
69
+ * registerPassportWebRoutes(router, {
70
+ * authorizeMiddleware: [CsrfMiddleware()],
71
+ * })
72
+ * ```
73
+ *
74
+ * Don't do both — CsrfMiddleware running twice on the same request
75
+ * emits duplicate `Set-Cookie`s on GETs and runs validation twice on
76
+ * POSTs.
77
+ *
78
+ * Accepts a single handler or an array. Empty / omitted means no
79
+ * additional middleware is applied — the typical case for apps that
80
+ * already CSRF-guard at the group level.
81
+ */
82
+ authorizeMiddleware?: MiddlewareHandler | MiddlewareHandler[];
83
+ /**
84
+ * Middleware applied to the device-flow endpoints — `POST /oauth/device/code`
85
+ * and `POST /oauth/device/approve`. RFC 8628 §5.2 calls for brute-force
86
+ * protection on the user_code surface (8-char alphabet → 32^8 ≈ 1.1×10^12
87
+ * keyspace; per-IP throttling makes exhaustion infeasible).
88
+ *
89
+ * Most apps should NOT need this option. The recommended pattern is to
90
+ * mount a rate limiter on the entire api group from `bootstrap/app.ts`
91
+ * (`withMiddleware((m) => m.api(RateLimit.perMinute(60)))`) — that single
92
+ * hook covers the device endpoints alongside every other api route, and
93
+ * 60/min per-IP is already enough that exhausting the user_code keyspace
94
+ * would take tens of thousands of years.
95
+ *
96
+ * `deviceMiddleware` is the per-route fallback for apps that want a
97
+ * tighter device-specific limit (e.g. `RateLimit.perMinute(5)`) on top of
98
+ * — or in place of — the group default:
99
+ *
100
+ * ```ts
101
+ * import { RateLimit } from '@rudderjs/middleware'
102
+ * import { registerPassportApiRoutes } from '@rudderjs/passport'
103
+ *
104
+ * registerPassportApiRoutes(router, {
105
+ * deviceMiddleware: [RateLimit.perMinute(5).by((req) => req.ip)],
106
+ * })
107
+ * ```
108
+ *
109
+ * Layered limits compose in sequence — group + per-route both run, with
110
+ * the tightest budget winning. Locking individual user_codes after N
111
+ * misses (the stateful half of the original RFC 8628 §5.2 guidance)
112
+ * isn't covered by RateLimit; if you need it, wrap your own middleware.
113
+ *
114
+ * Accepts a single handler or an array. Empty / omitted means no
115
+ * additional middleware is applied — the typical case for apps that
116
+ * already throttle the api group.
117
+ */
118
+ deviceMiddleware?: MiddlewareHandler | MiddlewareHandler[];
16
119
  }
17
120
  /**
18
121
  * Register all Passport OAuth routes on the given router.
@@ -29,5 +132,51 @@ export interface PassportRouteOptions {
29
132
  * registerPassportRoutes(router, { except: ['authorize', 'scopes'] })
30
133
  */
31
134
  export declare function registerPassportRoutes(router: Router, opts?: PassportRouteOptions): void;
135
+ /**
136
+ * Register the **web-group** Passport routes — `GET/POST/DELETE
137
+ * /oauth/authorize` (the consent flow) and `DELETE /oauth/tokens/:id`
138
+ * (revoke). These endpoints depend on session + authenticated user
139
+ * resolution, so they belong on the same router that handles your
140
+ * application's logged-in pages.
141
+ *
142
+ * `POST /oauth/authorize` requires CSRF protection. The recommended
143
+ * pattern is to mount `CsrfMiddleware` on the entire web group from
144
+ * `bootstrap/app.ts` (`withMiddleware((m) => m.web(CsrfMiddleware()))`)
145
+ * — that single hook covers /oauth/authorize plus every other
146
+ * state-changing web route. Apps that don't have group-level CSRF can
147
+ * use the per-route fallback via `authorizeMiddleware: [CsrfMiddleware()]`
148
+ * — see PassportRouteOptions.authorizeMiddleware. Don't do both.
149
+ *
150
+ * Thin wrapper around `registerPassportRoutes(router, { except: ['token',
151
+ * 'scopes', 'device'] })`. Use `registerPassportApiRoutes()` for the
152
+ * stateless half on the api group.
153
+ */
154
+ export declare function registerPassportWebRoutes(router: Router, opts?: PassportRouteOptions): void;
155
+ /**
156
+ * Register the **api-group** Passport routes — `POST /oauth/token`,
157
+ * `POST /oauth/device/code`, `POST /oauth/device/approve`, and `GET
158
+ * /oauth/scopes`. These endpoints are stateless (machine-to-machine), so
159
+ * they belong on the api router alongside your other JSON endpoints.
160
+ *
161
+ * `POST /oauth/token` is the canonical brute-force target — pass a rate
162
+ * limiter via `tokenMiddleware`:
163
+ *
164
+ * ```ts
165
+ * import { RateLimit } from '@rudderjs/middleware'
166
+ * import { registerPassportApiRoutes } from '@rudderjs/passport'
167
+ *
168
+ * // routes/api.ts
169
+ * registerPassportApiRoutes(router, {
170
+ * tokenMiddleware: [
171
+ * RateLimit.perMinute(10).by((req) => `${req.ip}:${req.body?.client_id}`),
172
+ * ],
173
+ * })
174
+ * ```
175
+ *
176
+ * Thin wrapper around `registerPassportRoutes(router, { except:
177
+ * ['authorize', 'revoke'] })`. Use `registerPassportWebRoutes()` for the
178
+ * stateful half on the web group.
179
+ */
180
+ export declare function registerPassportApiRoutes(router: Router, opts?: PassportRouteOptions): void;
32
181
  export {};
33
182
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAcA,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAA;AAE9D,UAAU,MAAM;IACd,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACpE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACrE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;CACxE;AAED,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAC1B,WAAW,GACX,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAA;AAEZ,MAAM,WAAW,oBAAoB;IACnC,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0EAA0E;IAC1E,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC9B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAiP5F"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAiK5D,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAA;AAE9D,UAAU,MAAM;IACd,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACpE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACrE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;CACxE;AAED,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAC1B,WAAW,GACX,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,QAAQ,CAAA;AAEZ,MAAM,WAAW,oBAAoB;IACnC,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0EAA0E;IAC1E,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAA;IAC7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,eAAe,CAAC,EAAE,iBAAiB,GAAG,iBAAiB,EAAE,CAAA;IACzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkCG;IACH,mBAAmB,CAAC,EAAE,iBAAiB,GAAG,iBAAiB,EAAE,CAAA;IAC7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkCG;IACH,gBAAgB,CAAC,EAAE,iBAAiB,GAAG,iBAAiB,EAAE,CAAA;CAC3D;AAOD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAsS5F;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAG/F;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAG/F"}
package/dist/routes.js CHANGED
@@ -1,5 +1,143 @@
1
+ import { config, report } from '@rudderjs/core';
1
2
  import { Passport } from './Passport.js';
3
+ import { clientHelpers } from './models/helpers.js';
4
+ import { RequireBearer } from './middleware/bearer.js';
2
5
  import { validateAuthorizationRequest, issueAuthCode, exchangeAuthCode, clientCredentialsGrant, refreshTokenGrant, requestDeviceCode, pollDeviceCode, approveDeviceCode, OAuthError, } from './grants/index.js';
6
+ /**
7
+ * Re-validate that `redirect_uri` is on the requesting client's whitelist.
8
+ * The consent UI sees the validated URI from `GET /oauth/authorize`, but the
9
+ * subsequent POST/DELETE bodies are attacker-controlled and must be
10
+ * re-checked — otherwise the response leaks an authorization code (POST) or
11
+ * an open redirect (DELETE) to a host the client never registered.
12
+ * Throws `OAuthError` so the surrounding try/catch returns the correct
13
+ * status + payload.
14
+ */
15
+ async function validateClientRedirect(clientId, redirectUri) {
16
+ if (typeof clientId !== 'string' || !clientId) {
17
+ throw new OAuthError('invalid_request', 'client_id is required.');
18
+ }
19
+ if (typeof redirectUri !== 'string' || !redirectUri) {
20
+ throw new OAuthError('invalid_request', 'redirect_uri is required.');
21
+ }
22
+ const ClientCls = await Passport.clientModel();
23
+ const client = await ClientCls.where('id', clientId).first();
24
+ if (!client || client.revoked) {
25
+ throw new OAuthError('invalid_client', 'Client not found.');
26
+ }
27
+ if (!clientHelpers.hasRedirectUri(client, redirectUri)) {
28
+ throw new OAuthError('invalid_request', 'Invalid redirect_uri.');
29
+ }
30
+ return client;
31
+ }
32
+ /**
33
+ * Resolve client credentials at the token endpoint per RFC 6749 §2.3.
34
+ *
35
+ * Confidential clients can authenticate via:
36
+ * 1. `Authorization: Basic base64(client_id:client_secret)` (§2.3.1, MUST support)
37
+ * 2. `client_id` + `client_secret` in the request body (alternative)
38
+ *
39
+ * §2.3 forbids using both at once — clients MUST NOT pass credentials in
40
+ * the body when the header is present. We reject that combination with
41
+ * `invalid_request` so SDK bugs surface loudly instead of silently
42
+ * accepting one set and ignoring the other.
43
+ *
44
+ * Public clients send only `client_id` in the body; both Basic creds and
45
+ * a body `client_id` mismatch is also rejected.
46
+ */
47
+ function resolveClientCredentials(req, body) {
48
+ const authHeader = req.headers?.['authorization'];
49
+ const bodyClientId = body['client_id'];
50
+ const bodyClientSecret = body['client_secret'];
51
+ if (typeof authHeader === 'string' && authHeader.length >= 6 && authHeader.slice(0, 6).toLowerCase() === 'basic ') {
52
+ const encoded = authHeader.slice(6).trim();
53
+ let decoded;
54
+ try {
55
+ decoded = Buffer.from(encoded, 'base64').toString('utf8');
56
+ }
57
+ catch {
58
+ throw new OAuthError('invalid_request', 'Malformed HTTP Basic credentials.', 401);
59
+ }
60
+ const sep = decoded.indexOf(':');
61
+ if (sep === -1) {
62
+ throw new OAuthError('invalid_request', 'Malformed HTTP Basic credentials.', 401);
63
+ }
64
+ // RFC 6749 §2.3.1 — client_id and client_secret in Basic are
65
+ // application/x-www-form-urlencoded-encoded before base64. SDKs in
66
+ // the wild often skip the percent-encoding step; we accept the raw
67
+ // form because requiring percent-decoding here would reject every
68
+ // ASCII-only credential pair (which is the overwhelming majority).
69
+ const headerClientId = decoded.slice(0, sep);
70
+ const headerClientSecret = decoded.slice(sep + 1);
71
+ if (!headerClientId) {
72
+ throw new OAuthError('invalid_request', 'Malformed HTTP Basic credentials.', 401);
73
+ }
74
+ if (bodyClientSecret !== undefined) {
75
+ throw new OAuthError('invalid_request', 'client_secret must not be sent in both Authorization header and request body.', 401);
76
+ }
77
+ if (bodyClientId !== undefined && bodyClientId !== headerClientId) {
78
+ throw new OAuthError('invalid_request', 'client_id in Authorization header does not match request body.', 401);
79
+ }
80
+ return { clientId: headerClientId, clientSecret: headerClientSecret };
81
+ }
82
+ if (typeof bodyClientId !== 'string' || !bodyClientId) {
83
+ throw new OAuthError('invalid_request', 'client_id is required.');
84
+ }
85
+ return bodyClientSecret !== undefined
86
+ ? { clientId: bodyClientId, clientSecret: bodyClientSecret }
87
+ : { clientId: bodyClientId };
88
+ }
89
+ /**
90
+ * Resolve the device-flow verification URI in this priority order:
91
+ *
92
+ * 1. `opts.verificationUri` — explicit caller override.
93
+ * 2. `config('app.url')` — `${appUrl}${prefix}/device`. Trailing slash on
94
+ * the configured value is tolerated.
95
+ * 3. `req.protocol` + `req.hostname` — last-resort fallback for dev / when
96
+ * neither knob is configured. The `Host` header is attacker-controlled
97
+ * behind a reverse proxy without trust-proxy, so we emit a one-shot
98
+ * warning the first time we land here. Documented in CLAUDE.md.
99
+ */
100
+ let _hostHeaderFallbackWarned = false;
101
+ function resolveVerificationUri(opts, req, prefix) {
102
+ if (opts.verificationUri)
103
+ return opts.verificationUri;
104
+ const appUrl = config('app.url', undefined);
105
+ if (typeof appUrl === 'string' && appUrl) {
106
+ return `${appUrl.replace(/\/$/, '')}${prefix}/device`;
107
+ }
108
+ if (!_hostHeaderFallbackWarned) {
109
+ _hostHeaderFallbackWarned = true;
110
+ console.warn('[@rudderjs/passport] Falling back to req.protocol/req.hostname for the device-flow verification URI. ' +
111
+ 'The Host header is attacker-controlled behind a reverse proxy without trust-proxy. ' +
112
+ 'Set APP_URL (config(\'app.url\')) or pass an explicit `verificationUri` to registerPassportRoutes() to silence this.');
113
+ }
114
+ return `${req.protocol}://${req.hostname}${prefix}/device`;
115
+ }
116
+ /**
117
+ * Render an error response from a `/oauth/authorize` handler. RFC 6749
118
+ * §4.1.2.1 requires that `state` is echoed back on errors (so the client
119
+ * can reconcile the response against its own session) — independent of
120
+ * whether the response shape is a redirect or JSON, and independent of
121
+ * the underlying error code.
122
+ *
123
+ * We additionally call `report()` on non-`OAuthError` throws so the root
124
+ * cause surfaces through the configured exception reporter instead of
125
+ * being silently collapsed under `server_error`.
126
+ */
127
+ function authErrorResponse(res, err, state) {
128
+ const stateEcho = typeof state === 'string' && state ? { state } : {};
129
+ if (err instanceof OAuthError) {
130
+ res.status(err.statusCode).json({ ...err.toJSON(), ...stateEcho });
131
+ return;
132
+ }
133
+ report(err);
134
+ res.status(500).json({ error: 'server_error', error_description: 'Internal server error.', ...stateEcho });
135
+ }
136
+ function asMiddlewareArray(input) {
137
+ if (!input)
138
+ return [];
139
+ return Array.isArray(input) ? input : [input];
140
+ }
3
141
  /**
4
142
  * Register all Passport OAuth routes on the given router.
5
143
  *
@@ -19,12 +157,15 @@ export function registerPassportRoutes(router, opts = {}) {
19
157
  return;
20
158
  const prefix = opts.prefix ?? '/oauth';
21
159
  const skip = new Set(opts.except ?? []);
160
+ const tokenMiddleware = asMiddlewareArray(opts.tokenMiddleware);
161
+ const authorizeMiddleware = asMiddlewareArray(opts.authorizeMiddleware);
162
+ const deviceMiddleware = asMiddlewareArray(opts.deviceMiddleware);
22
163
  // ── /oauth/authorize ─────────────────────────────────────
23
164
  if (!skip.has('authorize')) {
24
165
  // GET /oauth/authorize — show consent (returns JSON or renders custom view)
25
166
  router.get(`${prefix}/authorize`, async (req, res) => {
167
+ const query = req.query ?? {};
26
168
  try {
27
- const query = req.query ?? {};
28
169
  const validated = await validateAuthorizationRequest({
29
170
  clientId: query['client_id'] ?? '',
30
171
  redirectUri: query['redirect_uri'] ?? '',
@@ -59,23 +200,22 @@ export function registerPassportRoutes(router, opts = {}) {
59
200
  });
60
201
  }
61
202
  catch (e) {
62
- if (e instanceof OAuthError) {
63
- res.status(e.statusCode).json(e.toJSON());
64
- }
65
- else {
66
- res.status(500).json({ error: 'server_error', error_description: 'Internal server error.' });
67
- }
203
+ authErrorResponse(res, e, query['state']);
68
204
  }
69
- });
205
+ }, authorizeMiddleware);
70
206
  // POST /oauth/authorize — user approves
71
207
  router.post(`${prefix}/authorize`, async (req, res) => {
208
+ const body = req.body ?? {};
72
209
  try {
73
- const body = req.body ?? {};
74
210
  const userId = req.raw?.__rjs_user?.id ?? req.user?.id;
75
211
  if (!userId) {
76
- res.status(401).json({ error: 'unauthenticated', error_description: 'User must be signed in.' });
212
+ // Echo state on the unauthenticated branch too the consent UI
213
+ // round-trips the same payload regardless of the auth gate result.
214
+ const stateEcho = typeof body['state'] === 'string' && body['state'] ? { state: body['state'] } : {};
215
+ res.status(401).json({ error: 'unauthenticated', error_description: 'User must be signed in.', ...stateEcho });
77
216
  return;
78
217
  }
218
+ await validateClientRedirect(body['client_id'], body['redirect_uri']);
79
219
  const code = await issueAuthCode({
80
220
  userId,
81
221
  clientId: body['client_id'],
@@ -91,48 +231,64 @@ export function registerPassportRoutes(router, opts = {}) {
91
231
  res.json({ redirect_uri: redirectUri.toString() });
92
232
  }
93
233
  catch (e) {
94
- if (e instanceof OAuthError) {
95
- res.status(e.statusCode).json(e.toJSON());
96
- }
97
- else {
98
- res.status(500).json({ error: 'server_error', error_description: 'Internal server error.' });
99
- }
234
+ authErrorResponse(res, e, body['state']);
100
235
  }
101
- });
236
+ }, authorizeMiddleware);
102
237
  // DELETE /oauth/authorize — user denies
103
238
  router.delete(`${prefix}/authorize`, async (req, res) => {
104
239
  const body = req.body ?? {};
105
- const redirectUri = new URL(body['redirect_uri'] ?? 'http://localhost');
106
- redirectUri.searchParams.set('error', 'access_denied');
107
- redirectUri.searchParams.set('error_description', 'The user denied the request.');
108
- if (body['state'])
109
- redirectUri.searchParams.set('state', body['state']);
110
- res.json({ redirect_uri: redirectUri.toString() });
111
- });
240
+ try {
241
+ await validateClientRedirect(body['client_id'], body['redirect_uri']);
242
+ const redirectUri = new URL(body['redirect_uri']);
243
+ redirectUri.searchParams.set('error', 'access_denied');
244
+ redirectUri.searchParams.set('error_description', 'The user denied the request.');
245
+ if (body['state'])
246
+ redirectUri.searchParams.set('state', body['state']);
247
+ res.json({ redirect_uri: redirectUri.toString() });
248
+ }
249
+ catch (e) {
250
+ authErrorResponse(res, e, body['state']);
251
+ }
252
+ }, authorizeMiddleware);
112
253
  }
113
254
  // ── POST /oauth/token ────────────────────────────────────
114
255
  if (!skip.has('token')) {
256
+ // `tokenMiddleware` runs ahead of the handler — primary intended use is
257
+ // a per-route rate limiter so client_secret guessing can't churn through
258
+ // the registry without backoff. See PassportRouteOptions.tokenMiddleware
259
+ // jsdoc for the recommended config.
115
260
  router.post(`${prefix}/token`, async (req, res) => {
116
261
  try {
117
262
  const body = req.body ?? {};
118
263
  const grantType = body['grant_type'];
264
+ // RFC 6749 §2.3.1 — confidential clients MUST be able to
265
+ // authenticate via HTTP Basic; body params are an alternative.
266
+ // §2.3 forbids using both at once. Resolve credentials once for
267
+ // all grants instead of repeating the parsing in each branch.
268
+ const credentials = resolveClientCredentials(req, body);
119
269
  let result;
120
270
  switch (grantType) {
121
271
  case 'authorization_code':
122
272
  result = await exchangeAuthCode({
123
273
  grantType,
124
274
  code: body['code'],
125
- clientId: body['client_id'],
126
- clientSecret: body['client_secret'],
275
+ ...credentials,
127
276
  redirectUri: body['redirect_uri'],
128
277
  codeVerifier: body['code_verifier'],
129
278
  });
130
279
  break;
131
280
  case 'client_credentials':
281
+ // ClientCredentialsRequest requires clientSecret (the grant
282
+ // is confidential-only by spec). Surface the missing-secret
283
+ // case as invalid_request rather than letting it surface
284
+ // downstream as "Invalid client secret."
285
+ if (credentials.clientSecret === undefined) {
286
+ throw new OAuthError('invalid_request', 'client_secret is required for the client_credentials grant.', 401);
287
+ }
132
288
  result = await clientCredentialsGrant({
133
289
  grantType,
134
- clientId: body['client_id'],
135
- clientSecret: body['client_secret'],
290
+ clientId: credentials.clientId,
291
+ clientSecret: credentials.clientSecret,
136
292
  scope: body['scope'],
137
293
  });
138
294
  break;
@@ -140,8 +296,7 @@ export function registerPassportRoutes(router, opts = {}) {
140
296
  result = await refreshTokenGrant({
141
297
  grantType,
142
298
  refreshToken: body['refresh_token'],
143
- clientId: body['client_id'],
144
- clientSecret: body['client_secret'],
299
+ ...credentials,
145
300
  scope: body['scope'],
146
301
  });
147
302
  break;
@@ -149,15 +304,26 @@ export function registerPassportRoutes(router, opts = {}) {
149
304
  const pollResult = await pollDeviceCode({
150
305
  grantType,
151
306
  deviceCode: body['device_code'],
152
- clientId: body['client_id'],
307
+ clientId: credentials.clientId,
153
308
  });
154
309
  if (pollResult.status === 'authorized') {
155
310
  result = pollResult.tokens;
156
311
  }
157
312
  else {
158
- res.status(pollResult.status === 'slow_down' ? 429 : 400).json({
159
- error: pollResult.status,
160
- });
313
+ // RFC 8628 §3.5 device-flow polling errors (including
314
+ // slow_down) are §5.2-shaped errors and MUST return HTTP
315
+ // 400. 429 is for transport-level rate-limiting, not the
316
+ // OAuth `slow_down` signal.
317
+ //
318
+ // On slow_down, forward the escalated `interval` so a
319
+ // well-behaved client uses the new value instead of having
320
+ // to add 5 itself. Other variants don't need it.
321
+ if (pollResult.status === 'slow_down') {
322
+ res.status(400).json({ error: 'slow_down', interval: pollResult.interval });
323
+ }
324
+ else {
325
+ res.status(400).json({ error: pollResult.status });
326
+ }
161
327
  return;
162
328
  }
163
329
  break;
@@ -173,27 +339,42 @@ export function registerPassportRoutes(router, opts = {}) {
173
339
  }
174
340
  catch (e) {
175
341
  if (e instanceof OAuthError) {
342
+ // RFC 6749 §5.2 — client-auth failures at the token endpoint
343
+ // are signalled with WWW-Authenticate alongside the 401 status.
344
+ if (e.statusCode === 401 && typeof res.header === 'function') {
345
+ res.header('WWW-Authenticate', 'Basic realm="oauth"');
346
+ }
176
347
  res.status(e.statusCode).json(e.toJSON());
177
348
  }
178
349
  else {
350
+ report(e);
179
351
  res.status(500).json({ error: 'server_error', error_description: 'Internal server error.' });
180
352
  }
181
353
  }
182
- });
354
+ }, tokenMiddleware);
183
355
  }
184
356
  // ── DELETE /oauth/tokens/:id — revoke a specific token ──
357
+ // Requires a valid bearer token AND ownership of the token being revoked.
358
+ // Token ids appear in JWT `jti` claims (semi-public), so without an
359
+ // ownership check anyone with a single captured JWT could DoS arbitrary
360
+ // users. Returns 404 (not 403) on ownership mismatch to avoid leaking
361
+ // whether a given id exists.
185
362
  if (!skip.has('revoke')) {
186
363
  router.delete(`${prefix}/tokens/:id`, async (req, res) => {
187
364
  const tokenId = req.params?.['id'] ?? '';
188
365
  const AccessTokenCls = await Passport.tokenModel();
189
366
  const token = await AccessTokenCls.where('id', tokenId).first();
190
- if (!token) {
367
+ const requesterId = req.raw?.__rjs_user?.id ?? req.user?.id;
368
+ if (!token || !requesterId || token.userId !== requesterId) {
191
369
  res.status(404).json({ error: 'not_found', error_description: 'Token not found.' });
192
370
  return;
193
371
  }
194
- await AccessTokenCls.update(token.id, { revoked: true });
372
+ // QueryBuilder.updateAll() bypasses the mass-assignment filter;
373
+ // `revoked` is no longer in `AccessToken.fillable`.
374
+ await AccessTokenCls.where('id', token.id)
375
+ .updateAll({ revoked: true });
195
376
  res.status(204).send();
196
- });
377
+ }, [RequireBearer(), ...authorizeMiddleware]);
197
378
  }
198
379
  // ── GET /oauth/scopes ────────────────────────────────────
199
380
  if (!skip.has('scopes')) {
@@ -204,10 +385,13 @@ export function registerPassportRoutes(router, opts = {}) {
204
385
  // ── /oauth/device ────────────────────────────────────────
205
386
  if (!skip.has('device')) {
206
387
  // POST /oauth/device/code — request device authorization
388
+ // `deviceMiddleware` runs ahead of the handler — primary intended use is
389
+ // a per-route rate limiter tighter than the api-group default. See
390
+ // PassportRouteOptions.deviceMiddleware jsdoc.
207
391
  router.post(`${prefix}/device/code`, async (req, res) => {
208
392
  try {
209
393
  const body = req.body ?? {};
210
- const verificationUri = opts.verificationUri ?? `${req.protocol}://${req.hostname}${prefix}/device`;
394
+ const verificationUri = resolveVerificationUri(opts, req, prefix);
211
395
  const result = await requestDeviceCode({
212
396
  clientId: body['client_id'],
213
397
  scope: body['scope'],
@@ -220,10 +404,11 @@ export function registerPassportRoutes(router, opts = {}) {
220
404
  res.status(e.statusCode).json(e.toJSON());
221
405
  }
222
406
  else {
407
+ report(e);
223
408
  res.status(500).json({ error: 'server_error', error_description: 'Internal server error.' });
224
409
  }
225
410
  }
226
- });
411
+ }, deviceMiddleware);
227
412
  // POST /oauth/device/approve — user approves/denies device
228
413
  router.post(`${prefix}/device/approve`, async (req, res) => {
229
414
  try {
@@ -241,10 +426,63 @@ export function registerPassportRoutes(router, opts = {}) {
241
426
  res.status(e.statusCode).json(e.toJSON());
242
427
  }
243
428
  else {
429
+ report(e);
244
430
  res.status(500).json({ error: 'server_error', error_description: 'Internal server error.' });
245
431
  }
246
432
  }
247
- });
433
+ }, deviceMiddleware);
248
434
  }
249
435
  }
436
+ /**
437
+ * Register the **web-group** Passport routes — `GET/POST/DELETE
438
+ * /oauth/authorize` (the consent flow) and `DELETE /oauth/tokens/:id`
439
+ * (revoke). These endpoints depend on session + authenticated user
440
+ * resolution, so they belong on the same router that handles your
441
+ * application's logged-in pages.
442
+ *
443
+ * `POST /oauth/authorize` requires CSRF protection. The recommended
444
+ * pattern is to mount `CsrfMiddleware` on the entire web group from
445
+ * `bootstrap/app.ts` (`withMiddleware((m) => m.web(CsrfMiddleware()))`)
446
+ * — that single hook covers /oauth/authorize plus every other
447
+ * state-changing web route. Apps that don't have group-level CSRF can
448
+ * use the per-route fallback via `authorizeMiddleware: [CsrfMiddleware()]`
449
+ * — see PassportRouteOptions.authorizeMiddleware. Don't do both.
450
+ *
451
+ * Thin wrapper around `registerPassportRoutes(router, { except: ['token',
452
+ * 'scopes', 'device'] })`. Use `registerPassportApiRoutes()` for the
453
+ * stateless half on the api group.
454
+ */
455
+ export function registerPassportWebRoutes(router, opts = {}) {
456
+ const except = new Set([...(opts.except ?? []), 'token', 'scopes', 'device']);
457
+ registerPassportRoutes(router, { ...opts, except: Array.from(except) });
458
+ }
459
+ /**
460
+ * Register the **api-group** Passport routes — `POST /oauth/token`,
461
+ * `POST /oauth/device/code`, `POST /oauth/device/approve`, and `GET
462
+ * /oauth/scopes`. These endpoints are stateless (machine-to-machine), so
463
+ * they belong on the api router alongside your other JSON endpoints.
464
+ *
465
+ * `POST /oauth/token` is the canonical brute-force target — pass a rate
466
+ * limiter via `tokenMiddleware`:
467
+ *
468
+ * ```ts
469
+ * import { RateLimit } from '@rudderjs/middleware'
470
+ * import { registerPassportApiRoutes } from '@rudderjs/passport'
471
+ *
472
+ * // routes/api.ts
473
+ * registerPassportApiRoutes(router, {
474
+ * tokenMiddleware: [
475
+ * RateLimit.perMinute(10).by((req) => `${req.ip}:${req.body?.client_id}`),
476
+ * ],
477
+ * })
478
+ * ```
479
+ *
480
+ * Thin wrapper around `registerPassportRoutes(router, { except:
481
+ * ['authorize', 'revoke'] })`. Use `registerPassportWebRoutes()` for the
482
+ * stateful half on the web group.
483
+ */
484
+ export function registerPassportApiRoutes(router, opts = {}) {
485
+ const except = new Set([...(opts.except ?? []), 'authorize', 'revoke']);
486
+ registerPassportRoutes(router, { ...opts, except: Array.from(except) });
487
+ }
250
488
  //# sourceMappingURL=routes.js.map