@cloud-cli/s3mini 1.47.2 → 1.48.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/README.md +22 -3
- package/dist/auth/oidc.d.ts +5 -0
- package/dist/auth/oidc.js +69 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/handlers/router.js +107 -17
- package/dist/handlers/router.js.map +1 -1
- package/dist/storage/s3mini.js +23 -7
- package/dist/storage/s3mini.js.map +1 -1
- package/dist/test/models.test.js +3 -0
- package/dist/test/models.test.js.map +1 -1
- package/dist/test/router.test.js +134 -8
- package/dist/test/router.test.js.map +1 -1
- package/dist/test/s3-compatibility.test.d.ts +1 -0
- package/dist/test/s3-compatibility.test.js +47 -0
- package/dist/test/s3-compatibility.test.js.map +1 -0
- package/dist/test/storage.test.js +16 -1
- package/dist/test/storage.test.js.map +1 -1
- package/dist/types/models.js +1 -1
- package/dist/types/models.js.map +1 -1
- package/dist/ui/control-plane.d.ts +1 -0
- package/dist/ui/control-plane.js +166 -0
- package/dist/ui/control-plane.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ the resulting `.env` file or any client secret.
|
|
|
24
24
|
```sh
|
|
25
25
|
S3MINI_OIDC_CLIENT_ID=your-client-id
|
|
26
26
|
S3MINI_OIDC_CLIENT_SECRET=your-client-secret
|
|
27
|
-
S3MINI_OIDC_REDIRECT_URI=https://storage.example.com/
|
|
27
|
+
S3MINI_OIDC_REDIRECT_URI=https://storage.example.com/auth/callback
|
|
28
28
|
S3MINI_OIDC_AUDIENCE=your-client-id
|
|
29
29
|
S3MINI_OIDC_ADMIN_EMAILS=admin@example.com
|
|
30
30
|
```
|
|
@@ -35,11 +35,30 @@ The deployment may use the live provider names `AUTH_PROVIDER`,
|
|
|
35
35
|
provider origin or the `/api` documentation URL; both are normalized to the
|
|
36
36
|
OIDC endpoint origin.
|
|
37
37
|
|
|
38
|
-
Register the exact redirect URI with the OIDC client. `S3MINI_OIDC_AUTH_URL`
|
|
38
|
+
Register the exact `/auth/callback` redirect URI with the OIDC client. `S3MINI_OIDC_AUTH_URL`
|
|
39
39
|
can override the provider base URL. The admin email allowlist is optional; when
|
|
40
40
|
omitted, any authenticated provider user is accepted. Client secrets and
|
|
41
41
|
allowlists must remain in environment variables or local untracked config.
|
|
42
42
|
|
|
43
|
+
S3MINI follows the provider's Node client flow: authorization-code PKCE uses
|
|
44
|
+
`/authorize` and `/token`, access tokens are verified as RS256 JWTs using
|
|
45
|
+
`/.well-known/jwks.json`, and user identity is loaded from `/userinfo` with
|
|
46
|
+
`X-Auth-Audience`.
|
|
47
|
+
|
|
48
|
+
Provider API tokens can also call S3MINI directly with an `Authorization:
|
|
49
|
+
Bearer` header. Create them through the provider's `/api-tokens/{clientId}`
|
|
50
|
+
endpoint and grant these scopes as needed:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
s3:read GET, HEAD, and OPTIONS requests
|
|
54
|
+
s3:write bucket/object mutations
|
|
55
|
+
s3:admin bucket policy/ACL/configuration and `/admin` control-plane actions
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use `s3:*` for a full-access token. S3MINI introspects opaque provider tokens
|
|
59
|
+
through `/oauth/introspect` using the configured OIDC client credentials.
|
|
60
|
+
|
|
43
61
|
Set `S3MINI_REPLICATION_QUORUM` to a positive number to expose a degraded
|
|
44
62
|
state when fewer than that many configured peers are healthy. Current writes
|
|
45
|
-
remain asynchronous; quorum acknowledgement enforcement is
|
|
63
|
+
remain asynchronous; quorum acknowledgement enforcement is intentionally
|
|
64
|
+
deferred while the project focuses on core S3 behavior and small deployments.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function verifyOidcToken(token: string, issuer: string, audience: string): Promise<void>;
|
|
2
|
+
export declare function introspectOidcToken(token: string, issuer: string, clientId: string, clientSecret: string): Promise<{
|
|
3
|
+
active: boolean;
|
|
4
|
+
scopes: string[];
|
|
5
|
+
}>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
const jwksCache = new Map();
|
|
3
|
+
const introspectionCache = new Map();
|
|
4
|
+
function decodeBase64Url(value) {
|
|
5
|
+
return Buffer.from(value, 'base64url');
|
|
6
|
+
}
|
|
7
|
+
export async function verifyOidcToken(token, issuer, audience) {
|
|
8
|
+
const parts = token.split('.');
|
|
9
|
+
if (parts.length !== 3)
|
|
10
|
+
throw new Error('Invalid JWT');
|
|
11
|
+
let header;
|
|
12
|
+
let payload;
|
|
13
|
+
let signature;
|
|
14
|
+
try {
|
|
15
|
+
header = JSON.parse(decodeBase64Url(parts[0]).toString('utf8'));
|
|
16
|
+
payload = JSON.parse(decodeBase64Url(parts[1]).toString('utf8'));
|
|
17
|
+
signature = decodeBase64Url(parts[2]);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error('Invalid JWT');
|
|
21
|
+
}
|
|
22
|
+
if (header.alg !== 'RS256' || typeof header.kid !== 'string')
|
|
23
|
+
throw new Error('Unsupported JWT');
|
|
24
|
+
const cached = jwksCache.get(issuer);
|
|
25
|
+
let jwks;
|
|
26
|
+
if (cached && Date.now() < cached.expiresAt)
|
|
27
|
+
jwks = cached.keys;
|
|
28
|
+
else {
|
|
29
|
+
const response = await fetch(new URL('/.well-known/jwks.json', issuer));
|
|
30
|
+
if (!response.ok)
|
|
31
|
+
throw new Error(`Could not load JWKS: ${response.status}`);
|
|
32
|
+
jwks = await response.json();
|
|
33
|
+
jwksCache.set(issuer, { keys: jwks, expiresAt: Date.now() + 60 * 60 * 1000 });
|
|
34
|
+
}
|
|
35
|
+
const jwk = jwks.keys?.find(key => key.kid === header.kid && key.kty === 'RSA');
|
|
36
|
+
if (!jwk)
|
|
37
|
+
throw new Error('Unknown JWT signing key');
|
|
38
|
+
const key = createPublicKey({ key: jwk, format: 'jwk' });
|
|
39
|
+
if (!verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), key, signature))
|
|
40
|
+
throw new Error('Invalid JWT signature');
|
|
41
|
+
const now = Math.floor(Date.now() / 1000);
|
|
42
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
43
|
+
if (payload.iss !== issuer || !audiences.includes(audience) || typeof payload.sub !== 'string')
|
|
44
|
+
throw new Error('Invalid JWT claims');
|
|
45
|
+
if (typeof payload.exp !== 'number' || payload.exp <= now)
|
|
46
|
+
throw new Error('Expired JWT');
|
|
47
|
+
if (typeof payload.nbf === 'number' && payload.nbf > now)
|
|
48
|
+
throw new Error('JWT is not active');
|
|
49
|
+
}
|
|
50
|
+
export async function introspectOidcToken(token, issuer, clientId, clientSecret) {
|
|
51
|
+
const cached = introspectionCache.get(token);
|
|
52
|
+
if (cached && Date.now() < cached.expiresAt)
|
|
53
|
+
return { active: cached.active, scopes: cached.scopes };
|
|
54
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
55
|
+
const response = await fetch(new URL('/oauth/introspect', issuer), {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { Authorization: `Basic ${credentials}`, 'content-type': 'application/x-www-form-urlencoded' },
|
|
58
|
+
body: new URLSearchParams({ token, token_type_hint: 'access_token' }),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(`Could not introspect token: ${response.status}`);
|
|
62
|
+
const result = await response.json();
|
|
63
|
+
const scopes = Array.isArray(result.scope) ? result.scope : typeof result.scope === 'string' ? result.scope.split(/\s+/).filter(Boolean) : [];
|
|
64
|
+
const expiresAt = result.exp ? Math.min(result.exp * 1000, Date.now() + 30_000) : Date.now() + 30_000;
|
|
65
|
+
const value = { active: result.active === true, scopes, expiresAt };
|
|
66
|
+
introspectionCache.set(token, value);
|
|
67
|
+
return { active: value.active, scopes: value.scopes };
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=oidc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oidc.js","sourceRoot":"","sources":["../../src/auth/oidc.ts"],"names":[],"mappings":"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"}
|
package/dist/handlers/router.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { S3Error, VALID_STORAGE_CLASSES } from '../types/models.js';
|
|
3
3
|
import { verifyPresignedSigV4, verifySigV4 } from '../auth/sigv4.js';
|
|
4
|
+
import { introspectOidcToken, verifyOidcToken } from '../auth/oidc.js';
|
|
5
|
+
import { CONTROL_PLANE_HTML } from '../ui/control-plane.js';
|
|
4
6
|
export async function registerRoutes(fastify, s3, replication) {
|
|
5
7
|
fastify.addContentTypeParser(['application/octet-stream', 'application/xml', 'text/xml', 'text/csv'], { parseAs: 'buffer' }, (_request, body, done) => {
|
|
6
8
|
done(null, body);
|
|
@@ -14,6 +16,21 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
14
16
|
if (request.url === '/admin' || request.url.startsWith('/admin/'))
|
|
15
17
|
return;
|
|
16
18
|
const authorization = request.headers.authorization;
|
|
19
|
+
const bearerToken = authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
20
|
+
let oidcScopes = [];
|
|
21
|
+
if (bearerToken) {
|
|
22
|
+
if (!oidcConfigured())
|
|
23
|
+
throw new S3Error('AccessDenied', 'OIDC bearer tokens are not configured.', 403);
|
|
24
|
+
try {
|
|
25
|
+
const introspection = await introspectOidcToken(bearerToken, oidcBaseUrl(), oidcClientId(), oidcClientSecret());
|
|
26
|
+
if (!introspection.active)
|
|
27
|
+
throw new Error('The bearer token is inactive.');
|
|
28
|
+
oidcScopes = introspection.scopes;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new S3Error('AccessDenied', 'The bearer token is invalid or expired.', 403);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
const hasPresign = new URL(request.raw.url || '/', 'http://localhost').searchParams.has('X-Amz-Algorithm');
|
|
18
35
|
const credentials = await resolveCredentials(s3, request, hasPresign);
|
|
19
36
|
const accessKeyId = credentials?.accessKeyId;
|
|
@@ -22,19 +39,31 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
22
39
|
if (hasPresign && (!signingCredentials || !verifyPresignedSigV4({ method: request.method, url: request.raw.url || '/', headers: request.headers, body: Buffer.isBuffer(request.body) ? request.body : undefined }, signingCredentials))) {
|
|
23
40
|
throw new S3Error('SignatureDoesNotMatch', 'The presigned URL signature does not match.', 403);
|
|
24
41
|
}
|
|
25
|
-
if (authorization && (!signingCredentials || !verifySigV4({ method: request.method, url: request.raw.url || '/', headers: request.headers, body: Buffer.isBuffer(request.body) ? request.body : undefined }, signingCredentials))) {
|
|
42
|
+
if (authorization && !bearerToken && (!signingCredentials || !verifySigV4({ method: request.method, url: request.raw.url || '/', headers: request.headers, body: Buffer.isBuffer(request.body) ? request.body : undefined }, signingCredentials))) {
|
|
26
43
|
throw new S3Error('SignatureDoesNotMatch', 'The request signature does not match.', 403);
|
|
27
44
|
}
|
|
45
|
+
if (bearerToken && !hasOidcScope(oidcScopes, requiredOidcScope(request))) {
|
|
46
|
+
throw new S3Error('AccessDenied', `The bearer token lacks the required ${requiredOidcScope(request)} scope.`, 403);
|
|
47
|
+
}
|
|
28
48
|
if (credentials && (authorization || hasPresign))
|
|
29
49
|
await s3.markAccessKeyUsed(credentials.accessKeyId);
|
|
30
50
|
const params = request.params;
|
|
31
51
|
const query = request.query;
|
|
32
52
|
const key = params['*'] ? normalizeObjectKey(params['*']) : undefined;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
const credentialsConfigured = Boolean(process.env.S3MINI_ACCESS_KEY && process.env.S3MINI_SECRET_KEY);
|
|
54
|
+
if (params.bucket && credentialsConfigured && !authorization && !hasPresign && !['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
|
|
55
|
+
throw new S3Error('AccessDenied', 'Authentication is required.', 403, params.bucket, key);
|
|
56
|
+
}
|
|
57
|
+
const creatingBucket = !key && request.method === 'PUT' && Object.keys(query).every(name => name === 'locationConstraint');
|
|
58
|
+
if (params.bucket && !creatingBucket) {
|
|
59
|
+
const verb = request.method === 'GET' || request.method === 'HEAD' ? 'Get' : request.method === 'PUT' ? 'Put' : request.method === 'DELETE' ? 'Delete' : request.method;
|
|
60
|
+
const action = query.policy !== undefined
|
|
61
|
+
? `${verb}BucketPolicy`
|
|
62
|
+
: query.acl !== undefined
|
|
63
|
+
? key ? `${verb}ObjectAcl` : `${verb}BucketAcl`
|
|
64
|
+
: key
|
|
65
|
+
? `${verb}Object`
|
|
66
|
+
: request.method === 'GET' ? 'ListBucket' : `${request.method}Bucket`;
|
|
38
67
|
const authenticatedPrincipal = authorization || hasPresign ? accessKeyId || '' : 'anonymous';
|
|
39
68
|
const context = {
|
|
40
69
|
's3:x-amz-acl': String(request.headers['x-amz-acl'] || ''),
|
|
@@ -44,7 +73,7 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
44
73
|
if (await s3.isRequestDenied(params.bucket, key, `s3:${action}`, authenticatedPrincipal, context)) {
|
|
45
74
|
throw new S3Error('AccessDenied', 'Access denied by bucket policy.', 403, params.bucket, key);
|
|
46
75
|
}
|
|
47
|
-
if (credentialsConfigured && key && authenticatedPrincipal === 'anonymous' && ['GetObject', 'PutObject', 'DeleteObject'].includes(action) && await s3.isObjectRequestDenied(params.bucket, key, action, authenticatedPrincipal)) {
|
|
76
|
+
if (credentialsConfigured && key && authenticatedPrincipal === 'anonymous' && ['GetObject', 'PutObject', 'DeleteObject', 'GetObjectAcl', 'PutObjectAcl', 'DeleteObjectAcl'].includes(action) && await s3.isObjectRequestDenied(params.bucket, key, action, authenticatedPrincipal)) {
|
|
48
77
|
throw new S3Error('AccessDenied', 'Access denied by object ACL.', 403, params.bucket, key);
|
|
49
78
|
}
|
|
50
79
|
}
|
|
@@ -98,6 +127,17 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
98
127
|
const suppliedToken = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : undefined;
|
|
99
128
|
if (configuredToken && suppliedToken && timingSafeTokenEqual(suppliedToken, configuredToken))
|
|
100
129
|
return;
|
|
130
|
+
const bearerToken = request.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
131
|
+
if (bearerToken && oidcConfigured()) {
|
|
132
|
+
try {
|
|
133
|
+
const introspection = await introspectOidcToken(bearerToken, oidcBaseUrl(), oidcClientId(), oidcClientSecret());
|
|
134
|
+
if (introspection.active && hasOidcScope(introspection.scopes, 's3:admin'))
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Fall through to the standard access-denied response.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
101
141
|
const oidcToken = getCookie(request, 's3mini_oidc_token');
|
|
102
142
|
if (oidcToken && await isOidcAdmin(oidcToken))
|
|
103
143
|
return;
|
|
@@ -126,7 +166,9 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
126
166
|
}
|
|
127
167
|
async function isOidcAdmin(token) {
|
|
128
168
|
try {
|
|
129
|
-
const
|
|
169
|
+
const audience = process.env.S3MINI_OIDC_AUDIENCE || oidcClientId();
|
|
170
|
+
await verifyOidcToken(token, oidcBaseUrl(), audience);
|
|
171
|
+
const response = await fetch(`${oidcBaseUrl()}/userinfo`, { headers: { authorization: `Bearer ${token}`, 'x-auth-audience': audience } });
|
|
130
172
|
if (!response.ok)
|
|
131
173
|
return false;
|
|
132
174
|
const user = await response.json();
|
|
@@ -140,7 +182,7 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
140
182
|
fastify.get('/admin', async (request, reply) => {
|
|
141
183
|
if (oidcConfigured() && !getCookie(request, 's3mini_oidc_token'))
|
|
142
184
|
return reply.redirect('/admin/login');
|
|
143
|
-
return reply.type('text/html').send(
|
|
185
|
+
return reply.type('text/html').send(CONTROL_PLANE_HTML);
|
|
144
186
|
});
|
|
145
187
|
fastify.get('/admin/login', async (request, reply) => {
|
|
146
188
|
if (!oidcConfigured())
|
|
@@ -148,14 +190,14 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
148
190
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
149
191
|
const state = crypto.randomBytes(24).toString('base64url');
|
|
150
192
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
151
|
-
const redirectUri = process.env.OIDC_REDIRECT_URI || process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/
|
|
193
|
+
const redirectUri = process.env.OIDC_REDIRECT_URI || process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/auth/callback`;
|
|
152
194
|
const stateCookie = Buffer.from(JSON.stringify({ state, verifier }), 'utf8').toString('base64url');
|
|
153
195
|
reply.header('Set-Cookie', `s3mini_oidc_state=${stateCookie}; HttpOnly; Path=/admin; SameSite=Lax; Max-Age=600`);
|
|
154
196
|
const url = new URL(`${oidcBaseUrl()}/authorize`);
|
|
155
197
|
url.search = new URLSearchParams({ response_type: 'code', client_id: oidcClientId(), redirect_uri: redirectUri, state, scope: 'openid profile email', code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
|
156
198
|
return reply.redirect(url.toString());
|
|
157
199
|
});
|
|
158
|
-
fastify.get('/
|
|
200
|
+
fastify.get('/auth/callback', async (request, reply) => {
|
|
159
201
|
if (!oidcConfigured())
|
|
160
202
|
throw new S3Error('AccessDenied', 'OIDC is not configured.', 403);
|
|
161
203
|
const query = request.query;
|
|
@@ -171,7 +213,7 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
171
213
|
}
|
|
172
214
|
if (state.state !== query.state)
|
|
173
215
|
throw new S3Error('AccessDenied', 'The OIDC state does not match.', 403);
|
|
174
|
-
const redirectUri = process.env.OIDC_REDIRECT_URI || process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/
|
|
216
|
+
const redirectUri = process.env.OIDC_REDIRECT_URI || process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/auth/callback`;
|
|
175
217
|
const tokenResponse = await fetch(`${oidcBaseUrl()}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: query.code, client_id: oidcClientId(), client_secret: oidcClientSecret(), redirect_uri: redirectUri, code_verifier: state.verifier }) });
|
|
176
218
|
if (!tokenResponse.ok)
|
|
177
219
|
throw new S3Error('AccessDenied', 'The OIDC token exchange failed.', 403);
|
|
@@ -377,7 +419,7 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
377
419
|
if (!sourceBucket || !source.length)
|
|
378
420
|
throw new S3Error('InvalidRequest', 'x-amz-copy-source is invalid.', 400);
|
|
379
421
|
const obj = await s3.copyObject(sourceBucket, source.join('/'), params.bucket, key);
|
|
380
|
-
return reply.type('application/xml').code(200).send(wrapXml('CopyObjectResult', { ETag: obj.etag, LastModified: obj.lastModified.toISOString() }));
|
|
422
|
+
return reply.type('application/xml').code(200).header('ETag', obj.etag).header('x-amz-version-id', obj.versionId).send(wrapXml('CopyObjectResult', { ETag: obj.etag, LastModified: obj.lastModified.toISOString(), VersionId: obj.versionId }));
|
|
381
423
|
}
|
|
382
424
|
const body = request.body;
|
|
383
425
|
const checksum = request.headers['x-amz-checksum-sha256'];
|
|
@@ -450,7 +492,7 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
450
492
|
if (legalHold)
|
|
451
493
|
meta.legalHold = legalHold;
|
|
452
494
|
const obj = await s3.putObject(params.bucket, key, body, meta);
|
|
453
|
-
reply.type('application/xml').code(200).header('x-amz-checksum-sha256', checksumSha256).send(wrapXml('PutObjectResult', { ETag: obj.etag, ChecksumSHA256: checksumSha256 }));
|
|
495
|
+
reply.type('application/xml').code(200).header('ETag', obj.etag).header('x-amz-version-id', obj.versionId).header('x-amz-checksum-sha256', checksumSha256).send(wrapXml('PutObjectResult', { ETag: obj.etag, ChecksumSHA256: checksumSha256, VersionId: obj.versionId }));
|
|
454
496
|
}
|
|
455
497
|
async function getObject(request, reply) {
|
|
456
498
|
const params = request.params;
|
|
@@ -527,10 +569,14 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
527
569
|
.header('Content-Disposition', metadata.contentDisposition || '')
|
|
528
570
|
.header('Content-Encoding', metadata.contentEncoding || '')
|
|
529
571
|
.header('x-amz-checksum-sha256', checksumSha256)
|
|
572
|
+
.header('x-amz-storage-class', metadata.storageClass)
|
|
573
|
+
.header('x-amz-version-id', metadata.versionId)
|
|
530
574
|
.header('Accept-Ranges', 'bytes')
|
|
531
575
|
.code(status);
|
|
532
576
|
if (metadata.serverSideEncryption)
|
|
533
577
|
reply.header('x-amz-server-side-encryption', metadata.serverSideEncryption);
|
|
578
|
+
if (metadata.expires)
|
|
579
|
+
reply.header('Expires', metadata.expires.toUTCString());
|
|
534
580
|
if (metadata.sseKmsKeyId)
|
|
535
581
|
reply.header('x-amz-server-side-encryption-aws-kms-key-id', metadata.sseKmsKeyId);
|
|
536
582
|
if (metadata.objectLockMode)
|
|
@@ -551,7 +597,8 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
551
597
|
if (!key)
|
|
552
598
|
return headBucket(request, reply);
|
|
553
599
|
const query = request.query;
|
|
554
|
-
const
|
|
600
|
+
const object = await s3.getObject(params.bucket, key, query.versionId);
|
|
601
|
+
const { metadata } = object;
|
|
555
602
|
reply.code(200)
|
|
556
603
|
.header('ETag', metadata.etag)
|
|
557
604
|
.header('Last-Modified', metadata.lastModified.toUTCString())
|
|
@@ -561,7 +608,11 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
561
608
|
.header('Cache-Control', metadata.cacheControl || '')
|
|
562
609
|
.header('Content-Disposition', metadata.contentDisposition || '')
|
|
563
610
|
.header('Content-Encoding', metadata.contentEncoding || '')
|
|
564
|
-
.header('x-amz-checksum-sha256', crypto.createHash('sha256').update(
|
|
611
|
+
.header('x-amz-checksum-sha256', crypto.createHash('sha256').update(object.data).digest('base64'))
|
|
612
|
+
.header('x-amz-storage-class', metadata.storageClass)
|
|
613
|
+
.header('x-amz-version-id', metadata.versionId);
|
|
614
|
+
if (metadata.expires)
|
|
615
|
+
reply.header('Expires', metadata.expires.toUTCString());
|
|
565
616
|
if (metadata.serverSideEncryption)
|
|
566
617
|
reply.header('x-amz-server-side-encryption', metadata.serverSideEncryption);
|
|
567
618
|
if (metadata.sseKmsKeyId)
|
|
@@ -646,6 +697,36 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
646
697
|
const value = await s3.getBucketConfiguration(params.bucket, configuration);
|
|
647
698
|
return reply.type('application/xml').send(wrapXml(configuration, value || {}));
|
|
648
699
|
}
|
|
700
|
+
const listType = query['list-type'];
|
|
701
|
+
if (listType === '1') {
|
|
702
|
+
const marker = query.marker;
|
|
703
|
+
const result = await s3.listObjectsV2Advanced({
|
|
704
|
+
bucket: params.bucket,
|
|
705
|
+
prefix: query.prefix,
|
|
706
|
+
delimiter: query.delimiter,
|
|
707
|
+
maxKeys: query['max-keys'] ? Number(query['max-keys']) : undefined,
|
|
708
|
+
continuationToken: marker ? Buffer.from(marker).toString('base64url') : undefined,
|
|
709
|
+
encodingType: query['encoding-type'] === 'url' ? 'url' : undefined,
|
|
710
|
+
});
|
|
711
|
+
const encodeListValue = (value) => result.encodingType === 'url' ? encodeURIComponent(value) : value;
|
|
712
|
+
const lastKey = result.contents[result.contents.length - 1]?.key;
|
|
713
|
+
const lastPrefix = result.commonPrefixes[result.commonPrefixes.length - 1];
|
|
714
|
+
const nextMarker = lastPrefix || lastKey;
|
|
715
|
+
return reply.type('application/xml').send(wrapXml('ListBucketResult', {
|
|
716
|
+
Name: params.bucket,
|
|
717
|
+
Prefix: result.prefix,
|
|
718
|
+
Marker: marker,
|
|
719
|
+
NextMarker: result.isTruncated ? nextMarker : undefined,
|
|
720
|
+
MaxKeys: result.maxKeys,
|
|
721
|
+
IsTruncated: result.isTruncated,
|
|
722
|
+
Contents: result.contents.map(c => ({
|
|
723
|
+
Key: encodeListValue(c.key), LastModified: c.lastModified.toISOString(), ETag: c.etag,
|
|
724
|
+
Size: c.size, StorageClass: c.storageClass,
|
|
725
|
+
})),
|
|
726
|
+
CommonPrefixes: result.commonPrefixes.map(Prefix => ({ Prefix: encodeListValue(Prefix) })),
|
|
727
|
+
EncodingType: result.encodingType,
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
649
730
|
const result = await s3.listObjectsV2Advanced({
|
|
650
731
|
bucket: params.bucket,
|
|
651
732
|
prefix: query.prefix,
|
|
@@ -704,13 +785,22 @@ export async function registerRoutes(fastify, s3, replication) {
|
|
|
704
785
|
.map(match => ({ partNumber: Number(readXmlTag(match[1], 'PartNumber')), etag: unescapeXml(readXmlTag(match[1], 'ETag') || '') }))
|
|
705
786
|
.filter(part => Number.isInteger(part.partNumber) && part.partNumber > 0 && part.etag);
|
|
706
787
|
const result = await s3.completeMultipartUpload({ bucket: params.bucket, key, uploadId: query.uploadId, parts });
|
|
707
|
-
reply.type('application/xml').send(wrapXml('CompleteMultipartUploadResult', { Bucket: result.bucket, Key: result.key, ETag: result.etag }));
|
|
788
|
+
reply.type('application/xml').header('ETag', result.etag).header('x-amz-version-id', result.versionId).send(wrapXml('CompleteMultipartUploadResult', { Bucket: result.bucket, Key: result.key, ETag: result.etag, VersionId: result.versionId }));
|
|
708
789
|
});
|
|
709
790
|
fastify.get('/:bucket/*', { exposeHeadRoute: false }, getObject);
|
|
710
791
|
fastify.head('/:bucket/*', headObject);
|
|
711
792
|
fastify.delete('/:bucket/*', deleteObject);
|
|
712
793
|
fastify.get('/:bucket', { exposeHeadRoute: false }, listObjectsV2);
|
|
713
794
|
}
|
|
795
|
+
function hasOidcScope(scopes, required) {
|
|
796
|
+
return scopes.includes('*') || scopes.includes('s3:*') || scopes.includes(required);
|
|
797
|
+
}
|
|
798
|
+
function requiredOidcScope(request) {
|
|
799
|
+
const query = request.query;
|
|
800
|
+
if (query.policy !== undefined || query.acl !== undefined || query.encryption !== undefined || query.website !== undefined || query.logging !== undefined || query.notification !== undefined || query.replication !== undefined)
|
|
801
|
+
return 's3:admin';
|
|
802
|
+
return ['GET', 'HEAD', 'OPTIONS'].includes(request.method) ? 's3:read' : 's3:write';
|
|
803
|
+
}
|
|
714
804
|
function toXml(obj) {
|
|
715
805
|
if (typeof obj !== 'object' || obj === null)
|
|
716
806
|
return escapeXml(String(obj));
|