@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.
- package/dist/Passport.d.ts +93 -0
- package/dist/Passport.d.ts.map +1 -1
- package/dist/Passport.js +147 -0
- package/dist/Passport.js.map +1 -1
- package/dist/client-secret.d.ts +12 -0
- package/dist/client-secret.d.ts.map +1 -0
- package/dist/client-secret.js +63 -0
- package/dist/client-secret.js.map +1 -0
- package/dist/commands/client.d.ts +21 -0
- package/dist/commands/client.d.ts.map +1 -1
- package/dist/commands/client.js +27 -2
- package/dist/commands/client.js.map +1 -1
- package/dist/commands/keys.d.ts +28 -4
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +34 -4
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/purge.d.ts +6 -1
- package/dist/commands/purge.d.ts.map +1 -1
- package/dist/commands/purge.js +15 -31
- package/dist/commands/purge.js.map +1 -1
- package/dist/device-code-secret.d.ts +28 -0
- package/dist/device-code-secret.d.ts.map +1 -0
- package/dist/device-code-secret.js +31 -0
- package/dist/device-code-secret.js.map +1 -0
- package/dist/grants/authorization-code.d.ts +23 -0
- package/dist/grants/authorization-code.d.ts.map +1 -1
- package/dist/grants/authorization-code.js +126 -15
- package/dist/grants/authorization-code.js.map +1 -1
- package/dist/grants/client-credentials.d.ts.map +1 -1
- package/dist/grants/client-credentials.js +13 -5
- package/dist/grants/client-credentials.js.map +1 -1
- package/dist/grants/device-code.d.ts +10 -1
- package/dist/grants/device-code.d.ts.map +1 -1
- package/dist/grants/device-code.js +41 -10
- package/dist/grants/device-code.js.map +1 -1
- package/dist/grants/index.d.ts +1 -1
- package/dist/grants/index.d.ts.map +1 -1
- package/dist/grants/index.js +1 -1
- package/dist/grants/index.js.map +1 -1
- package/dist/grants/issue-tokens.d.ts +9 -0
- package/dist/grants/issue-tokens.d.ts.map +1 -1
- package/dist/grants/issue-tokens.js +39 -5
- package/dist/grants/issue-tokens.js.map +1 -1
- package/dist/grants/refresh-token.d.ts.map +1 -1
- package/dist/grants/refresh-token.js +64 -9
- package/dist/grants/refresh-token.js.map +1 -1
- package/dist/grants/safe-compare.d.ts +19 -0
- package/dist/grants/safe-compare.d.ts.map +1 -0
- package/dist/grants/safe-compare.js +28 -0
- package/dist/grants/safe-compare.js.map +1 -0
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +122 -67
- package/dist/index.js.map +1 -1
- package/dist/middleware/bearer.d.ts.map +1 -1
- package/dist/middleware/bearer.js +36 -6
- package/dist/middleware/bearer.js.map +1 -1
- package/dist/middleware/scope.d.ts +12 -2
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +46 -2
- package/dist/middleware/scope.js.map +1 -1
- package/dist/models/AccessToken.d.ts +32 -0
- package/dist/models/AccessToken.d.ts.map +1 -1
- package/dist/models/AccessToken.js +63 -3
- package/dist/models/AccessToken.js.map +1 -1
- package/dist/models/AuthCode.d.ts +16 -0
- package/dist/models/AuthCode.d.ts.map +1 -1
- package/dist/models/AuthCode.js +17 -1
- package/dist/models/AuthCode.js.map +1 -1
- package/dist/models/DeviceCode.d.ts +12 -2
- package/dist/models/DeviceCode.d.ts.map +1 -1
- package/dist/models/DeviceCode.js +7 -1
- package/dist/models/DeviceCode.js.map +1 -1
- package/dist/models/OAuthClient.d.ts +4 -0
- package/dist/models/OAuthClient.d.ts.map +1 -1
- package/dist/models/OAuthClient.js +13 -1
- package/dist/models/OAuthClient.js.map +1 -1
- package/dist/models/RefreshToken.d.ts +11 -0
- package/dist/models/RefreshToken.d.ts.map +1 -1
- package/dist/models/RefreshToken.js +12 -2
- package/dist/models/RefreshToken.js.map +1 -1
- package/dist/models/helpers.d.ts +6 -0
- package/dist/models/helpers.d.ts.map +1 -1
- package/dist/models/helpers.js +15 -2
- package/dist/models/helpers.js.map +1 -1
- package/dist/opaque-token.d.ts +32 -0
- package/dist/opaque-token.d.ts.map +1 -0
- package/dist/opaque-token.js +38 -0
- package/dist/opaque-token.js.map +1 -0
- package/dist/personal-access-tokens.d.ts.map +1 -1
- package/dist/personal-access-tokens.js +48 -10
- package/dist/personal-access-tokens.js.map +1 -1
- package/dist/routes.d.ts +149 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +279 -41
- package/dist/routes.js.map +1 -1
- package/dist/token.d.ts +80 -4
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +97 -13
- package/dist/token.js.map +1 -1
- package/package.json +5 -5
- 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
|
package/dist/routes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
redirectUri.searchParams.set('
|
|
110
|
-
|
|
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
|
-
|
|
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:
|
|
135
|
-
clientSecret:
|
|
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
|
-
|
|
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:
|
|
307
|
+
clientId: credentials.clientId,
|
|
153
308
|
});
|
|
154
309
|
if (pollResult.status === 'authorized') {
|
|
155
310
|
result = pollResult.tokens;
|
|
156
311
|
}
|
|
157
312
|
else {
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|