@dereekb/firebase-server 14.4.0 → 14.5.1
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/calcom/package.json +11 -10
- package/discord/package.json +14 -10
- package/index.esm.js +1295 -317
- package/mailgun/package.json +9 -9
- package/mcp/index.esm.js +483 -83
- package/mcp/package.json +12 -12
- package/mcp/src/lib/mcp.config.d.ts +73 -3
- package/mcp/src/lib/service/index.d.ts +1 -0
- package/mcp/src/lib/service/mcp.server.factory.d.ts +3 -2
- package/mcp/src/lib/service/mcp.tool-generator.d.ts +9 -0
- package/mcp/src/lib/service/tools/mcp.tool.cli-token.d.ts +62 -0
- package/model/package.json +13 -10
- package/oidc/index.esm.js +3186 -1456
- package/oidc/package.json +11 -11
- package/oidc/src/lib/controller/index.d.ts +3 -0
- package/oidc/src/lib/controller/oidc.cli-token.config.d.ts +323 -0
- package/oidc/src/lib/controller/oidc.cli-token.controller.d.ts +45 -0
- package/oidc/src/lib/controller/oidc.cli-token.service.d.ts +138 -0
- package/oidc/src/lib/controller/oidc.interaction.controller.d.ts +38 -0
- package/oidc/src/lib/middleware/oauth-auth.module.d.ts +8 -0
- package/oidc/src/lib/oidc.config.d.ts +9 -0
- package/oidc/src/lib/oidc.module.d.ts +1 -1
- package/oidc/src/lib/service/index.d.ts +1 -0
- package/oidc/src/lib/service/oidc.config.service.d.ts +8 -3
- package/oidc/src/lib/service/oidc.download-signer.d.ts +17 -0
- package/oidc/src/lib/service/oidc.jwt-signing.service.d.ts +35 -0
- package/package.json +13 -13
- package/src/lib/nest/controller/download/download.api.config.d.ts +203 -0
- package/src/lib/nest/controller/download/download.api.controller.d.ts +25 -0
- package/src/lib/nest/controller/download/download.api.module.d.ts +47 -0
- package/src/lib/nest/controller/download/download.api.service.d.ts +111 -0
- package/src/lib/nest/controller/download/index.d.ts +4 -0
- package/src/lib/nest/controller/index.d.ts +2 -0
- package/src/lib/nest/controller/request.ip.d.ts +60 -0
- package/test/index.esm.js +1 -0
- package/test/package.json +12 -12
- package/test/src/lib/oidc/oidc.test.flow.d.ts +10 -0
- package/twilio/package.json +8 -8
- package/zoho/package.json +14 -10
|
@@ -90,9 +90,14 @@ export declare class OidcProviderConfigService {
|
|
|
90
90
|
* provider does support those scopes, for the clients holding the profile. This narrower list is
|
|
91
91
|
* for metadata a client treats as a request template — notably an MCP protected-resource
|
|
92
92
|
* document's `scopes_supported`, which dynamic-registration clients (the Claude Code CLI) copy
|
|
93
|
-
* verbatim onto `/authorize`.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* verbatim onto `/authorize`. Defaulting such a document to this narrower list keeps an
|
|
94
|
+
* unassigned client from requesting a scope it can never be granted.
|
|
95
|
+
*
|
|
96
|
+
* A resource MAY still advertise an assignment-only scope deliberately (demo-api does for
|
|
97
|
+
* `token.cli`, so an MCP connector can ask for it at all). That is not fatal for the clients
|
|
98
|
+
* lacking the profile: the consent URL builder withholds the scope from them, so it lands in the
|
|
99
|
+
* submit's `rejected` set and the flow completes without it — the same treatment an admin-only
|
|
100
|
+
* scope gets for a non-admin.
|
|
96
101
|
*/
|
|
97
102
|
readonly clientRequestableScopesSupported: string[];
|
|
98
103
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DownloadTokenSigner } from '@dereekb/firebase-server';
|
|
2
|
+
import { type OidcJwtSigningService } from './oidc.jwt-signing.service';
|
|
3
|
+
/**
|
|
4
|
+
* Adapts {@link OidcJwtSigningService} to the `@dereekb/firebase-server` {@link DownloadTokenSigner}
|
|
5
|
+
* interface, so the signed asset-download endpoint can use the OIDC provider's own JWKS without
|
|
6
|
+
* `@dereekb/firebase-server` taking a dependency on this package.
|
|
7
|
+
*
|
|
8
|
+
* Using the provider's JWKS is the point: it is already shared across function instances and already
|
|
9
|
+
* rotates, so a download capability token needs no new secret to distribute. The `typ` + `aud`
|
|
10
|
+
* discriminators the download module passes in are what keep such a token from ever being accepted
|
|
11
|
+
* as an OAuth access token (and vice versa) even though both are signed by the same keys.
|
|
12
|
+
*
|
|
13
|
+
* @param signingService - The provider's JWT signing service.
|
|
14
|
+
* @returns A signer to register under the `DOWNLOAD_TOKEN_SIGNER` injection token.
|
|
15
|
+
* @__NO_SIDE_EFFECTS__
|
|
16
|
+
*/
|
|
17
|
+
export declare function oidcDownloadTokenSigner(signingService: OidcJwtSigningService): DownloadTokenSigner;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Maybe, type Milliseconds, type Seconds } from '@dereekb/util';
|
|
2
|
+
import { type JWTPayload } from 'jose';
|
|
2
3
|
import { OidcModuleConfig } from '../oidc.config';
|
|
3
4
|
import { JwksService } from './oidc.jwks.service';
|
|
4
5
|
/**
|
|
@@ -39,6 +40,23 @@ export interface OidcSignJwtInput {
|
|
|
39
40
|
*/
|
|
40
41
|
readonly typ?: Maybe<string>;
|
|
41
42
|
}
|
|
43
|
+
export interface OidcVerifyJwtInput {
|
|
44
|
+
/**
|
|
45
|
+
* The compact JWT to verify.
|
|
46
|
+
*/
|
|
47
|
+
readonly token: string;
|
|
48
|
+
/**
|
|
49
|
+
* Required `aud` claim value.
|
|
50
|
+
*/
|
|
51
|
+
readonly audience: string | readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Required header `typ`. Pass the SAME discriminator the token was minted with — a first-party JWT
|
|
54
|
+
* minted for a non-OAuth purpose (e.g. an asset-download capability) is signed by the same JWKS as
|
|
55
|
+
* the provider's `at+jwt` access tokens, so `typ` + `aud` are what keep the two from being
|
|
56
|
+
* interchangeable.
|
|
57
|
+
*/
|
|
58
|
+
readonly typ?: Maybe<string>;
|
|
59
|
+
}
|
|
42
60
|
export interface OidcSignedJwt {
|
|
43
61
|
readonly token: string;
|
|
44
62
|
readonly expiresAt: Date;
|
|
@@ -65,6 +83,7 @@ export declare class OidcJwtSigningService {
|
|
|
65
83
|
private readonly _jwks;
|
|
66
84
|
private readonly _config;
|
|
67
85
|
private _cached;
|
|
86
|
+
private _verifyKey;
|
|
68
87
|
constructor(_jwks: JwksService, _config: OidcModuleConfig);
|
|
69
88
|
/**
|
|
70
89
|
* Mints a signed JWT.
|
|
@@ -73,5 +92,21 @@ export declare class OidcJwtSigningService {
|
|
|
73
92
|
* @returns The compact token, its expiry, and the signing key id.
|
|
74
93
|
*/
|
|
75
94
|
signJwt(input: OidcSignJwtInput): Promise<OidcSignedJwt>;
|
|
95
|
+
/**
|
|
96
|
+
* Verifies a JWT that THIS provider signed, against its own JWKS.
|
|
97
|
+
*
|
|
98
|
+
* Enforces `iss` (this provider), `aud`, and — when supplied — the header `typ`. Returns
|
|
99
|
+
* `undefined` rather than throwing when the token does not verify, so a caller serving an
|
|
100
|
+
* unauthenticated route can answer with one generic error for every failure mode.
|
|
101
|
+
*
|
|
102
|
+
* The counterpart to {@link signJwt}: `verifyAccessToken` deliberately does NOT accept a token
|
|
103
|
+
* minted with a non-`at+jwt` `typ` and a non-OAuth audience, and this method is how a purpose-built
|
|
104
|
+
* token proves it is the RIGHT kind.
|
|
105
|
+
*
|
|
106
|
+
* @param input - The token, its required audience, and its required `typ`.
|
|
107
|
+
* @returns The verified payload, or `undefined` when verification fails.
|
|
108
|
+
*/
|
|
109
|
+
verifyJwt(input: OidcVerifyJwtInput): Promise<Maybe<JWTPayload>>;
|
|
110
|
+
private _getVerifyKey;
|
|
76
111
|
private _loadSigningKey;
|
|
77
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dereekb/firebase-server",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -58,18 +58,18 @@
|
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@cantoo/pdf-lib": ">=2.6.5 <2.11.0",
|
|
61
|
-
"@dereekb/analytics": "14.
|
|
62
|
-
"@dereekb/calcom": "14.
|
|
63
|
-
"@dereekb/date": "14.
|
|
64
|
-
"@dereekb/dbx-core": "14.
|
|
65
|
-
"@dereekb/discord": "14.
|
|
66
|
-
"@dereekb/firebase": "14.
|
|
67
|
-
"@dereekb/model": "14.
|
|
68
|
-
"@dereekb/nestjs": "14.
|
|
69
|
-
"@dereekb/oauth-resource": "14.
|
|
70
|
-
"@dereekb/rxjs": "14.
|
|
71
|
-
"@dereekb/util": "14.
|
|
72
|
-
"@dereekb/zoho": "14.
|
|
61
|
+
"@dereekb/analytics": "14.5.1",
|
|
62
|
+
"@dereekb/calcom": "14.5.1",
|
|
63
|
+
"@dereekb/date": "14.5.1",
|
|
64
|
+
"@dereekb/dbx-core": "14.5.1",
|
|
65
|
+
"@dereekb/discord": "14.5.1",
|
|
66
|
+
"@dereekb/firebase": "14.5.1",
|
|
67
|
+
"@dereekb/model": "14.5.1",
|
|
68
|
+
"@dereekb/nestjs": "14.5.1",
|
|
69
|
+
"@dereekb/oauth-resource": "14.5.1",
|
|
70
|
+
"@dereekb/rxjs": "14.5.1",
|
|
71
|
+
"@dereekb/util": "14.5.1",
|
|
72
|
+
"@dereekb/zoho": "14.5.1",
|
|
73
73
|
"@google-cloud/firestore": "^7.11.6",
|
|
74
74
|
"@google-cloud/storage": "^7.22.0",
|
|
75
75
|
"@modelcontextprotocol/node": "2.0.0",
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type Maybe, type Seconds, type WebsiteUrl } from '@dereekb/util';
|
|
2
|
+
/**
|
|
3
|
+
* Route prefix the download controller is mounted at. Under the `/api` global route prefix the route
|
|
4
|
+
* becomes `GET /api/download`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DOWNLOAD_API_ROUTE_PREFIX = "download";
|
|
7
|
+
/**
|
|
8
|
+
* Path (relative to the API base URL) of the signed asset-download endpoint.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DOWNLOAD_API_PATH = "/download";
|
|
11
|
+
/**
|
|
12
|
+
* Query parameter carrying the signed capability token.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DOWNLOAD_API_ASSET_QUERY_PARAM = "asset";
|
|
15
|
+
/**
|
|
16
|
+
* Folder, relative to the app's dist output, that holds every mintable asset.
|
|
17
|
+
*
|
|
18
|
+
* The downloadable set is exactly this ONE folder — nothing outside it is ever mintable, and the
|
|
19
|
+
* containment check in {@link resolveSecureAssetPath} is what enforces that.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_SECURE_ASSETS_DIRECTORY = "assets/secure";
|
|
22
|
+
/**
|
|
23
|
+
* `aud` claim of a download capability token.
|
|
24
|
+
*
|
|
25
|
+
* Distinct from every OAuth audience so a download token can never be presented as a bearer access
|
|
26
|
+
* token — `oidcProviderIssuerProfiles` only accepts the registered resource-server audiences and the
|
|
27
|
+
* issuer itself.
|
|
28
|
+
*/
|
|
29
|
+
export declare const DOWNLOAD_TOKEN_AUDIENCE = "dbx:asset-download";
|
|
30
|
+
/**
|
|
31
|
+
* Header `typ` of a download capability token.
|
|
32
|
+
*
|
|
33
|
+
* **Load-bearing.** The same JWKS signs the provider's `at+jwt` access tokens, so this is the second
|
|
34
|
+
* half of the mutual rejection: the download verifier accepts ONLY this `typ` (plus
|
|
35
|
+
* {@link DOWNLOAD_TOKEN_AUDIENCE}), and the bearer path rejects a download token on its audience.
|
|
36
|
+
*/
|
|
37
|
+
export declare const DOWNLOAD_TOKEN_TYP = "dbx-dl+jwt";
|
|
38
|
+
/**
|
|
39
|
+
* `sub` claim of a download capability token. The token carries NO user identifier — the caller is
|
|
40
|
+
* authorized at MINT time and the uid goes to the audit log there, so it never lands in a URL that
|
|
41
|
+
* ends up in shell history and proxy logs.
|
|
42
|
+
*/
|
|
43
|
+
export declare const DOWNLOAD_TOKEN_SUBJECT = "dbx:asset";
|
|
44
|
+
/**
|
|
45
|
+
* Claim carrying the asset path, RELATIVE to the secure root. Never absolute.
|
|
46
|
+
*/
|
|
47
|
+
export declare const DOWNLOAD_TOKEN_PATH_CLAIM = "p";
|
|
48
|
+
/**
|
|
49
|
+
* Default lifetime of a minted download URL.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_DOWNLOAD_TOKEN_TTL_SECONDS: Seconds;
|
|
52
|
+
/**
|
|
53
|
+
* Ceiling on a minted download URL's lifetime.
|
|
54
|
+
*
|
|
55
|
+
* A download URL is deliberately NOT one-time — a network blip partway through a multi-megabyte
|
|
56
|
+
* download would otherwise burn it — so the TTL is the whole control, with the token's `jti` logged
|
|
57
|
+
* so a replay is at least visible.
|
|
58
|
+
*/
|
|
59
|
+
export declare const MAX_DOWNLOAD_TOKEN_TTL_SECONDS: Seconds;
|
|
60
|
+
/**
|
|
61
|
+
* The verified claims of a download capability token.
|
|
62
|
+
*/
|
|
63
|
+
export interface DownloadTokenClaims {
|
|
64
|
+
/**
|
|
65
|
+
* The asset path, relative to the secure root.
|
|
66
|
+
*/
|
|
67
|
+
readonly path: string;
|
|
68
|
+
/**
|
|
69
|
+
* The token's unique id, logged so a replay is visible.
|
|
70
|
+
*/
|
|
71
|
+
readonly jti?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Input to {@link DownloadTokenSigner.signToken}.
|
|
75
|
+
*/
|
|
76
|
+
export interface DownloadTokenSignInput {
|
|
77
|
+
readonly claims: Record<string, unknown>;
|
|
78
|
+
readonly audience: string;
|
|
79
|
+
readonly subject: string;
|
|
80
|
+
readonly typ: string;
|
|
81
|
+
readonly expiresIn: Seconds;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Result of {@link DownloadTokenSigner.signToken}.
|
|
85
|
+
*/
|
|
86
|
+
export interface DownloadTokenSignResult {
|
|
87
|
+
readonly token: string;
|
|
88
|
+
readonly expiresAt: Date;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Input to {@link DownloadTokenSigner.verifyToken}.
|
|
92
|
+
*/
|
|
93
|
+
export interface DownloadTokenVerifyInput {
|
|
94
|
+
readonly token: string;
|
|
95
|
+
readonly audience: string;
|
|
96
|
+
readonly typ: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* App-supplied JWT signer/verifier backing the download capability token.
|
|
100
|
+
*
|
|
101
|
+
* Kept as an interface so `@dereekb/firebase-server` takes no dependency on the OIDC package: the
|
|
102
|
+
* app adapts `OidcJwtSigningService` (whose keys are the provider's own already-rotating JWKS, so
|
|
103
|
+
* there is no new secret to distribute). Nothing outside the download module knows how the token is
|
|
104
|
+
* BUILT — the signer only signs and verifies whatever claims it is handed.
|
|
105
|
+
*
|
|
106
|
+
* Without a signer the endpoint fails closed and no URL can be minted.
|
|
107
|
+
*/
|
|
108
|
+
export interface DownloadTokenSigner {
|
|
109
|
+
signToken(input: DownloadTokenSignInput): Promise<DownloadTokenSignResult>;
|
|
110
|
+
/**
|
|
111
|
+
* Verifies a token, returning its claims, or `undefined` when it is not a valid token of this
|
|
112
|
+
* `typ` + `audience` from this issuer.
|
|
113
|
+
*/
|
|
114
|
+
verifyToken(input: DownloadTokenVerifyInput): Promise<Maybe<Record<string, unknown>>>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* NestJS injection token for the {@link DownloadTokenSigner} provider.
|
|
118
|
+
*/
|
|
119
|
+
export declare const DOWNLOAD_TOKEN_SIGNER = "DOWNLOAD_TOKEN_SIGNER";
|
|
120
|
+
/**
|
|
121
|
+
* Configuration for the signed asset-download endpoint, supplied by the app via its dependency module.
|
|
122
|
+
*
|
|
123
|
+
* Without a resolvable {@link secureAssetsRoot} the endpoint fails closed for every request.
|
|
124
|
+
*/
|
|
125
|
+
export declare abstract class DownloadApiModuleConfig {
|
|
126
|
+
/**
|
|
127
|
+
* Absolute path to the ONLY folder assets may be served from.
|
|
128
|
+
*
|
|
129
|
+
* Resolve it with the same three-cwd probe the dist manifests use — the Functions runtime, a
|
|
130
|
+
* workspace-root run, and vitest all set different `process.cwd()` values.
|
|
131
|
+
*/
|
|
132
|
+
readonly secureAssetsRoot?: Maybe<string>;
|
|
133
|
+
/**
|
|
134
|
+
* The app's API base URL, used to build the absolute minted URL.
|
|
135
|
+
*/
|
|
136
|
+
readonly apiBaseUrl?: Maybe<WebsiteUrl>;
|
|
137
|
+
/**
|
|
138
|
+
* Default lifetime for a minted URL. Clamped to {@link MAX_DOWNLOAD_TOKEN_TTL_SECONDS}. Defaults to
|
|
139
|
+
* {@link DEFAULT_DOWNLOAD_TOKEN_TTL_SECONDS}.
|
|
140
|
+
*/
|
|
141
|
+
readonly defaultTtlSeconds?: Maybe<Seconds>;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolves an asset path, relative to the secure root, to the absolute file it names — or
|
|
145
|
+
* `undefined` when it escapes containment.
|
|
146
|
+
*
|
|
147
|
+
* **This function IS the security model for Part C.** With a folder root rather than a key registry,
|
|
148
|
+
* a mistake here is an arbitrary-file-read primitive on the function's filesystem, so it is enforced
|
|
149
|
+
* in BOTH places (at mint, so a URL for an outside file can never be obtained; and at download, so a
|
|
150
|
+
* bug in the minting path cannot become a read primitive) and both call sites share this one
|
|
151
|
+
* function so they cannot drift.
|
|
152
|
+
*
|
|
153
|
+
* Rejected up front: an absolute path, any `..` segment, a null byte, and an empty path. Then
|
|
154
|
+
* `realpath` is applied to BOTH sides — that is what stops a symlink inside `secure/` from pointing
|
|
155
|
+
* at `/etc` or at the function's `.env`. The final containment compare appends `path.sep` so a
|
|
156
|
+
* sibling directory sharing a name prefix (`secure-other/x`) cannot pass a naive `startsWith`.
|
|
157
|
+
*
|
|
158
|
+
* @param secureRoot - Absolute path to the secure root folder, or `undefined` when unconfigured (⇒ always fails).
|
|
159
|
+
* @param relativePath - The asset path relative to the secure root.
|
|
160
|
+
* @returns The absolute, real path of the file, or `undefined` when it is not a contained regular file.
|
|
161
|
+
* @__NO_SIDE_EFFECTS__
|
|
162
|
+
*/
|
|
163
|
+
export declare function resolveSecureAssetPath(secureRoot: Maybe<string>, relativePath: Maybe<string>): Maybe<string>;
|
|
164
|
+
/**
|
|
165
|
+
* Returns true when a relative asset path is structurally safe to resolve — non-empty, relative, free
|
|
166
|
+
* of `..` segments, and free of null bytes.
|
|
167
|
+
*
|
|
168
|
+
* Split out from {@link resolveSecureAssetPath} so the structural rejection is testable without a
|
|
169
|
+
* filesystem, and so the reason a path is refused is one check rather than a compound condition.
|
|
170
|
+
*
|
|
171
|
+
* @param relativePath - The candidate path.
|
|
172
|
+
* @returns True when the path may be resolved against the secure root.
|
|
173
|
+
* @__NO_SIDE_EFFECTS__
|
|
174
|
+
*/
|
|
175
|
+
export declare function isSafeRelativeAssetPath(relativePath: string): boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Clamps a requested download-URL lifetime to {@link MAX_DOWNLOAD_TOKEN_TTL_SECONDS}.
|
|
178
|
+
*
|
|
179
|
+
* @param requestedTtlSeconds - The requested lifetime, if any.
|
|
180
|
+
* @param defaultTtlSeconds - The app-configured default, used when none is requested.
|
|
181
|
+
* @returns The lifetime to sign with, in seconds.
|
|
182
|
+
* @__NO_SIDE_EFFECTS__
|
|
183
|
+
*/
|
|
184
|
+
export declare function downloadTokenTtlSeconds(requestedTtlSeconds: Maybe<Seconds>, defaultTtlSeconds?: Maybe<Seconds>): Seconds;
|
|
185
|
+
/**
|
|
186
|
+
* File-extension to `Content-Type` table for served assets.
|
|
187
|
+
*
|
|
188
|
+
* Intentionally tiny: the secure root holds build artifacts, and anything unrecognized is served as
|
|
189
|
+
* {@link DEFAULT_DOWNLOAD_CONTENT_TYPE} rather than being guessed at.
|
|
190
|
+
*/
|
|
191
|
+
export declare const DOWNLOAD_CONTENT_TYPES: Readonly<Record<string, string>>;
|
|
192
|
+
/**
|
|
193
|
+
* `Content-Type` used for any asset whose extension is not in {@link DOWNLOAD_CONTENT_TYPES}.
|
|
194
|
+
*/
|
|
195
|
+
export declare const DEFAULT_DOWNLOAD_CONTENT_TYPE = "application/octet-stream";
|
|
196
|
+
/**
|
|
197
|
+
* Resolves the `Content-Type` for a file path from its extension.
|
|
198
|
+
*
|
|
199
|
+
* @param filePath - The resolved file path.
|
|
200
|
+
* @returns The content type, defaulting to {@link DEFAULT_DOWNLOAD_CONTENT_TYPE}.
|
|
201
|
+
* @__NO_SIDE_EFFECTS__
|
|
202
|
+
*/
|
|
203
|
+
export declare function downloadContentTypeForPath(filePath: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Response } from 'express';
|
|
2
|
+
import { DownloadApiService } from './download.api.service';
|
|
3
|
+
/**
|
|
4
|
+
* REST controller that streams one registered asset to whoever holds a valid signed URL.
|
|
5
|
+
*
|
|
6
|
+
* Mounted at `download` — under the `/api` global prefix the route becomes `GET /api/download`.
|
|
7
|
+
*
|
|
8
|
+
* `/api/download` must **NOT** be listed in the OIDC module's `protectedPaths`: it authenticates via
|
|
9
|
+
* the signed `asset` query parameter, and a bearer middleware in front of it would 401 exactly the
|
|
10
|
+
* callers the feature exists for (a bare machine that has no credential yet — obtaining one is the
|
|
11
|
+
* whole point). Do not "fix" that by adding it.
|
|
12
|
+
*
|
|
13
|
+
* Follows `McpController`'s `@Res()` style; there is no `StreamableFile` precedent in this repo.
|
|
14
|
+
*/
|
|
15
|
+
export declare class DownloadApiController {
|
|
16
|
+
private readonly downloadService;
|
|
17
|
+
constructor(downloadService: DownloadApiService);
|
|
18
|
+
/**
|
|
19
|
+
* Streams the asset named by a signed capability token.
|
|
20
|
+
*
|
|
21
|
+
* @param asset - The signed token from the `asset` query parameter.
|
|
22
|
+
* @param res - The Express response the file is streamed to.
|
|
23
|
+
*/
|
|
24
|
+
downloadAsset(asset: string, res: Response): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ModuleMetadata } from '@nestjs/common';
|
|
2
|
+
import { type ClassType } from '@dereekb/util';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for {@link downloadApiModuleMetadata}.
|
|
5
|
+
*/
|
|
6
|
+
export interface DownloadApiModuleMetadataConfig extends Pick<ModuleMetadata, 'imports' | 'exports' | 'providers'> {
|
|
7
|
+
/**
|
|
8
|
+
* Module that exports the download endpoint's dependencies.
|
|
9
|
+
*
|
|
10
|
+
* Should provide:
|
|
11
|
+
* - {@link DownloadApiModuleConfig} — the absolute secure-assets root and the API base URL. Without
|
|
12
|
+
* a resolvable root every request fails closed.
|
|
13
|
+
* - `DOWNLOAD_TOKEN_SIGNER` — the app's JWT signer/verifier, typically adapting
|
|
14
|
+
* `OidcJwtSigningService` so the provider's own JWKS signs the capability tokens.
|
|
15
|
+
*/
|
|
16
|
+
readonly dependencyModule: ClassType;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Generates NestJS module metadata for the signed asset-download API.
|
|
20
|
+
*
|
|
21
|
+
* Mirrors `sessionApiModuleMetadata`: the consumer provides a dependency module exposing the
|
|
22
|
+
* required tokens and this factory wires the controller + service.
|
|
23
|
+
*
|
|
24
|
+
* Do NOT add `'/api/download'` to the OIDC module's `protectedPaths` — the route authenticates via
|
|
25
|
+
* its signed `asset` query parameter, and a bearer middleware would 401 the very callers it exists
|
|
26
|
+
* for.
|
|
27
|
+
*
|
|
28
|
+
* @param metadataConfig - Configuration including the dependency module.
|
|
29
|
+
* @returns NestJS module metadata exposing the download controller + service.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* @Module({
|
|
34
|
+
* imports: [MyOidcModule],
|
|
35
|
+
* providers: [
|
|
36
|
+
* { provide: DownloadApiModuleConfig, useValue: { secureAssetsRoot, apiBaseUrl } },
|
|
37
|
+
* { provide: DOWNLOAD_TOKEN_SIGNER, useFactory: oidcDownloadTokenSignerFactory, inject: [OidcJwtSigningService] }
|
|
38
|
+
* ],
|
|
39
|
+
* exports: [DownloadApiModuleConfig, DOWNLOAD_TOKEN_SIGNER]
|
|
40
|
+
* })
|
|
41
|
+
* export class MyDownloadDependencyModule {}
|
|
42
|
+
*
|
|
43
|
+
* @Module(downloadApiModuleMetadata({ dependencyModule: MyDownloadDependencyModule }))
|
|
44
|
+
* export class MyDownloadApiModule {}
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function downloadApiModuleMetadata(metadataConfig: DownloadApiModuleMetadataConfig): ModuleMetadata;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type Readable } from 'node:stream';
|
|
2
|
+
import { type ISO8601DateString, type Maybe, type Seconds, type WebsiteUrl } from '@dereekb/util';
|
|
3
|
+
import { DownloadApiModuleConfig, type DownloadTokenSigner } from './download.api.config';
|
|
4
|
+
/**
|
|
5
|
+
* Error code returned when the `asset` token is missing, malformed, or not a valid download token.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DOWNLOAD_INVALID_TOKEN_ERROR_CODE = "DOWNLOAD_INVALID_TOKEN_ERROR";
|
|
8
|
+
/**
|
|
9
|
+
* Error code returned when the token verifies but names nothing servable — a missing file, a
|
|
10
|
+
* directory, or anything that fails containment. Deliberately one code for all three so the endpoint
|
|
11
|
+
* does not report whether a given path exists outside the secure root.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DOWNLOAD_ASSET_NOT_FOUND_ERROR_CODE = "DOWNLOAD_ASSET_NOT_FOUND_ERROR";
|
|
14
|
+
/**
|
|
15
|
+
* Error code returned when minting is asked for an asset that is not inside the secure root.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DOWNLOAD_ASSET_NOT_MINTABLE_ERROR_CODE = "DOWNLOAD_ASSET_NOT_MINTABLE_ERROR";
|
|
18
|
+
/**
|
|
19
|
+
* Input to {@link DownloadApiService.downloadUrlForAsset}.
|
|
20
|
+
*/
|
|
21
|
+
export interface DownloadUrlForAssetInput {
|
|
22
|
+
/**
|
|
23
|
+
* The asset path RELATIVE to the secure root, e.g. `demo-cli`.
|
|
24
|
+
*/
|
|
25
|
+
readonly path: string;
|
|
26
|
+
/**
|
|
27
|
+
* Optional lifetime in seconds. Clamped to one hour.
|
|
28
|
+
*/
|
|
29
|
+
readonly ttlSeconds?: Maybe<Seconds>;
|
|
30
|
+
/**
|
|
31
|
+
* Optional uid of the caller the URL is being minted for, written to the audit log. NOT put in the
|
|
32
|
+
* token — a download URL lands in shell history and proxy logs.
|
|
33
|
+
*/
|
|
34
|
+
readonly forUid?: Maybe<string>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A minted download URL.
|
|
38
|
+
*/
|
|
39
|
+
export interface DownloadUrlForAssetResult {
|
|
40
|
+
readonly url: WebsiteUrl;
|
|
41
|
+
readonly expiresAt: ISO8601DateString;
|
|
42
|
+
/**
|
|
43
|
+
* SHA-256 of the artifact, so the caller can verify what it fetched.
|
|
44
|
+
*/
|
|
45
|
+
readonly sha256: string;
|
|
46
|
+
/**
|
|
47
|
+
* Size of the artifact in bytes.
|
|
48
|
+
*/
|
|
49
|
+
readonly size: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A resolved, contained asset ready to be streamed.
|
|
53
|
+
*/
|
|
54
|
+
export interface ResolvedDownloadAsset {
|
|
55
|
+
readonly absolutePath: string;
|
|
56
|
+
readonly fileName: string;
|
|
57
|
+
readonly contentType: string;
|
|
58
|
+
readonly size: number;
|
|
59
|
+
readonly stream: () => Readable;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mints and resolves signed, self-authenticating asset-download URLs.
|
|
63
|
+
*
|
|
64
|
+
* A minted URL is a capability: anything holding it can fetch exactly one file until it expires. The
|
|
65
|
+
* downloadable set is ONE folder (the app's configured secure root) and the containment check in
|
|
66
|
+
* `resolveSecureAssetPath` runs on BOTH sides — refusing to sign a token for anything outside the
|
|
67
|
+
* root, and refusing to serve one even if a signed token somehow names an outside path.
|
|
68
|
+
*
|
|
69
|
+
* The token is signed with the OIDC provider's own JWKS through an app-supplied
|
|
70
|
+
* {@link DownloadTokenSigner}, so it needs no new secret and rotates with the provider's keys. Its
|
|
71
|
+
* `typ` (`dbx-dl+jwt`) and `aud` (`dbx:asset-download`) are load-bearing discriminators: the bearer
|
|
72
|
+
* middleware must never accept a download token, and this verifier must never accept an access token.
|
|
73
|
+
*/
|
|
74
|
+
export declare class DownloadApiService {
|
|
75
|
+
private readonly config?;
|
|
76
|
+
private readonly signer?;
|
|
77
|
+
private readonly _logger;
|
|
78
|
+
constructor(config?: DownloadApiModuleConfig | undefined, signer?: DownloadTokenSigner | undefined);
|
|
79
|
+
/**
|
|
80
|
+
* Whether the download path is usable at all (a signer AND a secure root are configured).
|
|
81
|
+
*/
|
|
82
|
+
get enabled(): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Mints a signed, self-authenticating URL for one asset inside the secure root.
|
|
85
|
+
*
|
|
86
|
+
* @param input - The asset path, optional TTL, and the minting caller's uid (for the audit log only).
|
|
87
|
+
* @returns The absolute URL, its expiry, and the artifact's SHA-256 + size.
|
|
88
|
+
* @throws {HttpsError} A `400` when the path is not a servable file inside the secure root, or when the endpoint is not configured.
|
|
89
|
+
*/
|
|
90
|
+
downloadUrlForAsset(input: DownloadUrlForAssetInput): Promise<DownloadUrlForAssetResult>;
|
|
91
|
+
/**
|
|
92
|
+
* Verifies a download token and resolves the asset it names, re-running containment.
|
|
93
|
+
*
|
|
94
|
+
* @param rawToken - The raw `asset` query parameter.
|
|
95
|
+
* @returns The resolved asset, ready to stream.
|
|
96
|
+
* @throws {HttpsError} A `401` for a missing/invalid/expired token, or a `404` for anything not servable.
|
|
97
|
+
*/
|
|
98
|
+
resolveDownloadRequest(rawToken: Maybe<string>): Promise<ResolvedDownloadAsset>;
|
|
99
|
+
private _invalidTokenError;
|
|
100
|
+
private _notFoundError;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Computes the SHA-256 of a file, hex-encoded.
|
|
104
|
+
*
|
|
105
|
+
* Read fully into memory rather than streamed: the secure root holds build artifacts in the tens of
|
|
106
|
+
* megabytes at most, and the hash is only computed at mint time (not per download).
|
|
107
|
+
*
|
|
108
|
+
* @param absolutePath - The absolute path to hash.
|
|
109
|
+
* @returns The lowercase hex digest.
|
|
110
|
+
*/
|
|
111
|
+
export declare function sha256ForFile(absolutePath: string): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Maybe } from '@dereekb/util';
|
|
2
|
+
/**
|
|
3
|
+
* Header carrying the originating client address through a proxy chain, as a comma-delimited list
|
|
4
|
+
* whose FIRST entry is the original caller.
|
|
5
|
+
*
|
|
6
|
+
* Cloud Run / Cloud Functions sit behind Google's front end, which always rewrites this header, so
|
|
7
|
+
* the leftmost value is trustworthy there. Behind an arbitrary reverse proxy it is caller-controlled
|
|
8
|
+
* — see {@link requestClientIp}'s note on what this address may and may not be used for.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FORWARDED_FOR_REQUEST_HEADER = "x-forwarded-for";
|
|
11
|
+
/**
|
|
12
|
+
* The minimal request shape {@link requestClientIp} reads. Declared structurally so this module (and
|
|
13
|
+
* therefore `@dereekb/firebase-server` core) takes no dependency on Express' types, letting both the
|
|
14
|
+
* HTTP controller and an MCP tool handler pass whatever request object they hold.
|
|
15
|
+
*/
|
|
16
|
+
export interface ClientIpRequest {
|
|
17
|
+
readonly headers?: Maybe<Record<string, Maybe<string | string[]>>>;
|
|
18
|
+
readonly ip?: Maybe<string>;
|
|
19
|
+
readonly socket?: Maybe<{
|
|
20
|
+
readonly remoteAddress?: Maybe<string>;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the calling client's IP address from a request, preferring the leftmost
|
|
25
|
+
* {@link FORWARDED_FOR_REQUEST_HEADER} entry and falling back to Express' own `req.ip` and then the
|
|
26
|
+
* raw socket address.
|
|
27
|
+
*
|
|
28
|
+
* IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1`) are normalized to their dotted-quad form, so the
|
|
29
|
+
* same caller reaching a dual-stack listener and a v4-only one compares equal.
|
|
30
|
+
*
|
|
31
|
+
* **This is an advisory address, not an authentication factor.** Behind a proxy that does not
|
|
32
|
+
* rewrite the header it is caller-controlled, and a NAT/mobile caller's address changes on its own.
|
|
33
|
+
* Use it for binding a capability to the network path that minted it (an opt-in defence in depth
|
|
34
|
+
* alongside a real gate) or for logging — never as the sole check.
|
|
35
|
+
*
|
|
36
|
+
* @param request - The incoming request.
|
|
37
|
+
* @returns The resolved client address, or `undefined` when none could be determined.
|
|
38
|
+
* @__NO_SIDE_EFFECTS__
|
|
39
|
+
*/
|
|
40
|
+
export declare function requestClientIp(request: Maybe<ClientIpRequest>): Maybe<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Trims an address and unwraps the IPv4-mapped IPv6 form, returning `undefined` for an empty value.
|
|
43
|
+
*
|
|
44
|
+
* @param value - The raw address.
|
|
45
|
+
* @returns The normalized address, or `undefined` when there is nothing usable.
|
|
46
|
+
* @__NO_SIDE_EFFECTS__
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeClientIp(value: Maybe<string>): Maybe<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Compares two client addresses for the purposes of a mint/redeem binding.
|
|
51
|
+
*
|
|
52
|
+
* Both sides are normalized first, and an ABSENT address on either side is treated as a MISMATCH:
|
|
53
|
+
* a binding that silently passes when the address could not be resolved would be no binding at all.
|
|
54
|
+
*
|
|
55
|
+
* @param a - The first address.
|
|
56
|
+
* @param b - The second address.
|
|
57
|
+
* @returns True when both addresses resolved and are equal.
|
|
58
|
+
* @__NO_SIDE_EFFECTS__
|
|
59
|
+
*/
|
|
60
|
+
export declare function clientIpsMatch(a: Maybe<string>, b: Maybe<string>): boolean;
|
package/test/index.esm.js
CHANGED
|
@@ -5089,6 +5089,7 @@ function _unsupported_iterable_to_array(o, minLen) {
|
|
|
5089
5089
|
callbackUrl: callbackUrl,
|
|
5090
5090
|
accessToken: tokenBody === null || tokenBody === void 0 ? void 0 : tokenBody.access_token,
|
|
5091
5091
|
idToken: tokenBody === null || tokenBody === void 0 ? void 0 : tokenBody.id_token,
|
|
5092
|
+
refreshToken: tokenBody === null || tokenBody === void 0 ? void 0 : tokenBody.refresh_token,
|
|
5092
5093
|
tokenType: tokenBody === null || tokenBody === void 0 ? void 0 : tokenBody.token_type,
|
|
5093
5094
|
scope: tokenBody === null || tokenBody === void 0 ? void 0 : tokenBody.scope,
|
|
5094
5095
|
session: {
|
package/test/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dereekb/firebase-server/test",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.5.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"peerDependencies": {
|
|
7
|
-
"@dereekb/analytics": "14.
|
|
8
|
-
"@dereekb/date": "14.
|
|
9
|
-
"@dereekb/firebase": "14.
|
|
10
|
-
"@dereekb/firebase-server": "14.
|
|
11
|
-
"@dereekb/firebase-server/oidc": "14.
|
|
12
|
-
"@dereekb/model": "14.
|
|
13
|
-
"@dereekb/nestjs": "14.
|
|
14
|
-
"@dereekb/oauth-resource": "14.
|
|
15
|
-
"@dereekb/rxjs": "14.
|
|
16
|
-
"@dereekb/util": "14.
|
|
7
|
+
"@dereekb/analytics": "14.5.1",
|
|
8
|
+
"@dereekb/date": "14.5.1",
|
|
9
|
+
"@dereekb/firebase": "14.5.1",
|
|
10
|
+
"@dereekb/firebase-server": "14.5.1",
|
|
11
|
+
"@dereekb/firebase-server/oidc": "14.5.1",
|
|
12
|
+
"@dereekb/model": "14.5.1",
|
|
13
|
+
"@dereekb/nestjs": "14.5.1",
|
|
14
|
+
"@dereekb/oauth-resource": "14.5.1",
|
|
15
|
+
"@dereekb/rxjs": "14.5.1",
|
|
16
|
+
"@dereekb/util": "14.5.1",
|
|
17
17
|
"@google-cloud/firestore": "^7.11.6",
|
|
18
18
|
"@google-cloud/storage": "^7.22.0",
|
|
19
19
|
"@nestjs/common": "^12.0.1",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"supertest": "^7.2.2"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@dereekb/nestjs": "14.
|
|
29
|
+
"@dereekb/nestjs": "14.5.1"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
"./package.json": "./package.json",
|