@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 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/admin/callback
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 planned.
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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"}
@@ -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
- if (params.bucket && query.policy === undefined && query.acl === undefined && (key || request.method !== 'PUT')) {
34
- const action = key
35
- ? `${request.method === 'GET' || request.method === 'HEAD' ? 'Get' : request.method === 'PUT' ? 'Put' : request.method === 'DELETE' ? 'Delete' : request.method}Object`
36
- : request.method === 'GET' ? 'ListBucket' : `${request.method}Bucket`;
37
- const credentialsConfigured = Boolean(process.env.S3MINI_ACCESS_KEY && process.env.S3MINI_SECRET_KEY);
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 response = await fetch(`${oidcBaseUrl()}/userinfo`, { headers: { authorization: `Bearer ${token}`, 'x-auth-audience': process.env.S3MINI_OIDC_AUDIENCE || oidcClientId() } });
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(ADMIN_HTML);
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)}/admin/callback`;
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('/admin/callback', async (request, reply) => {
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)}/admin/callback`;
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 { metadata } = await s3.getObject(params.bucket, key, query.versionId);
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((await s3.getObject(params.bucket, key)).data).digest('base64'));
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));