Repository: strapi-community/strapi-provider-upload-google-cloud-storage

Issue:
[BUG] Implicit authentication for signed URLs no longer working in 5.0.3
## 🐛 Bug Description
Since 5.0.3 of the plugin my strapi isn't able to generate signed URLs anymore in my App Engine instance. There is always the error 
`Cannot generate signed URLs without service account credentials. Either: 1. Provide serviceAccount with client_email and private_key in your configuration, or 2. Set publicFiles to true to use direct URLs instead of signed URLs.`

My config that hasn't changed is: 
`upload: {
        config: {
          provider: '@strapi-community/strapi-provider-upload-google-cloud-storage',
          providerOptions: {
              bucketName: env('UPLOAD_BUCKET'),
              publicFiles: false,
              uniform: true,
              basePath: ''
          },
        },
    }`

So it's true, I am not providing any serviceAccount but according to the README, I am not supposed to when running strapi on Google Cloud infrastructure. And everything is working fine in 5.0.2.

## 🔍 Steps to Reproduce
1. Install 5.0.3
2. run strapi with the above config on an App Engine Standard environment
3. try to add a media object to a collection item
4. See error in the Logs

## 💡 Expected Behavior
No bug, just delivering signed URL

## 📸 Screenshots
s. logs above

## 💻 Environment
- Strapi Version: 5.15.1 
- Provider Version: 5.0.3
- Node Version: 22.16.0
- OS: Linux


Discussion:
@cyp3rius: Released as part of `v5.0.5`

Solution patch:
diff --git a/README.md b/README.md
index 3c4c386..a0f9337 100644
--- a/README.md
+++ b/README.md
@@ -197,10 +197,45 @@ module.exports = [
 
 #### `serviceAccount` :
 
-JSON data provide by Google Account (explained before). If you are deploying to a GCP product that supports Application Default credentials, you can leave this omitted, and authentication will work automatically.
+JSON data provide by Google Account (explained before). 
+
+**For GCP environments (App Engine, Cloud Run, Cloud Functions, GKE, Compute Engine):**
+You can leave this omitted, and authentication will work automatically using Application Default Credentials (ADC). The provider will detect the GCP environment and attempt to use ADC for signing URLs.
+
+**For non-GCP environments (local development, on-premises, other cloud providers):**
+You must provide explicit service account credentials with both `client_email` and `private_key` fields for signed URL generation when `publicFiles` is set to `false`.
+
+**Behavior:**
+- **GCP environment + no serviceAccount**: Uses ADC for signed URLs ✅
+- **GCP environment + explicit serviceAccount**: Uses provided credentials ✅  
+- **Non-GCP environment + no serviceAccount + publicFiles: true**: Returns direct URLs with warning ⚠️
+- **Non-GCP environment + no serviceAccount + publicFiles: false**: Throws error ❌
+- **Non-GCP environment + explicit serviceAccount**: Uses provided credentials ✅
 
 Can be set as a String, JSON Object, or omitted.
 
+**Example for GCP environments (minimal setup):**
+```js
+// No serviceAccount needed - uses ADC automatically
+{
+  bucketName: 'my-bucket',
+  publicFiles: false, // Signed URLs will work with ADC
+}
+```
+
+**Example for non-GCP environments:**
+```js
+{
+  bucketName: 'my-bucket',
+  publicFiles: false,
+  serviceAccount: {
+    project_id: 'your-project',
+    client_email: 'your-service-account@your-project.iam.gserviceaccount.com',
+    private_key: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n'
+  }
+}
+```
+
 #### `bucketName` :
 
 The name of the bucket on Google Cloud Storage.
@@ -376,6 +411,31 @@ Follow this step :
 - Copy the full content of the file
 - Paste it under the variable `ServiceAccount` in `plugins.js` config file in JSON
 
+#### Signed URL Generation Issues
+
+**Error: `Cannot generate signed URLs without service account credentials`**
+
+This error occurs when:
+1. You're running in a **non-GCP environment** (local development, other cloud providers)
+2. You have `publicFiles: false` (requiring signed URLs)
+3. You haven't provided explicit `serviceAccount` credentials
+
+**Solutions:**
+- For **GCP environments**: Ensure your service account has proper permissions (`Storage Object Admin` or `Storage Admin` role)
+- For **non-GCP environments**: Provide explicit `serviceAccount` configuration with `client_email` and `private_key`
+- Alternatively: Set `publicFiles: true` to use direct URLs instead of signed URLs
+
+**Error: `Failed to generate signed URL in GCP environment`**
+
+This error occurs in GCP environments when Application Default Credentials (ADC) cannot sign URLs, typically due to:
+1. Insufficient permissions on the default service account
+2. Missing IAM roles for URL signing
+
+**Solutions:**
+- Ensure your GCP service account has `Storage Object Admin` or `Storage Admin` role
+- Verify that the default service account has signing permissions
+- Consider providing explicit `serviceAccount` credentials if ADC continues to fail
+
 ## 🔗 Links
 
 - [Strapi website](http://strapi.io/)
diff --git a/src/index.ts b/src/index.ts
index 23d6700..7c1bfbb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -91,28 +91,8 @@ export default {
         return !config.publicFiles;
       },
       async getSignedUrl(file: File) {
-        // Check if we can sign URLs (requires service account with client_email)
-        if (!serviceAccount || !serviceAccount.client_email) {
-          // If using ADC or missing client_email, we cannot generate signed URLs
-          if (!config.publicFiles) {
-            throw new Error(
-              'Cannot generate signed URLs without service account credentials. ' +
-                'Either:\n' +
-                '1. Provide serviceAccount with client_email and private_key in your configuration, or\n' +
-                '2. Set publicFiles to true to use direct URLs instead of signed URLs.\n' +
-                'For more information, see: https://github.com/strapi-community/strapi-provider-upload-google-cloud-storage#setting-up-google-authentication'
-            );
-          }
-
-          // Fallback to direct URL for public files
-          console.warn(
-            'Warning: Cannot generate signed URL without service account credentials. ' +
-              'Returning direct URL instead. This works only for public files.'
-          );
-          return { url: file.url };
-        }
-
         try {
+          // First, try to generate signed URL - this works with ADC in GCP environments
           const options: GetSignedUrlConfig = {
             version: 'v4',
             action: 'read',
@@ -122,16 +102,74 @@ export default {
           const [url] = await GCS.bucket(config.bucketName).file(fileName).getSignedUrl(options);
           return { url };
         } catch (error) {
+          // If signing fails, check if this is a credentials issue
           if (error instanceof Error && error.message.includes('Cannot sign data without')) {
+            // Check if we're in a GCP environment where ADC should work
+            const isGCPEnvironment = this.detectGCPEnvironment();
+
+            if (!isGCPEnvironment && (!serviceAccount || !serviceAccount.client_email)) {
+              // Non-GCP environment requires explicit service account credentials
+              if (!config.publicFiles) {
+                throw new Error(
+                  'Cannot generate signed URLs without service account credentials. ' +
+                    'Either:\n' +
+                    '1. Provide serviceAccount with client_email and private_key in your configuration, or\n' +
+                    '2. Set publicFiles to true to use direct URLs instead of signed URLs.\n' +
+                    'For more information, see: https://github.com/strapi-community/strapi-provider-upload-google-cloud-storage#setting-up-google-authentication'
+                );
+              }
+
+              // Fallback to direct URL for public files in non-GCP environments
+              console.warn(
+                'Warning: Cannot generate signed URL without service account credentials. ' +
+                  'Returning direct URL instead. This works only for public files.'
+              );
+              return { url: file.url };
+            }
+
+            // For GCP environments, provide more specific error message
+            if (isGCPEnvironment) {
+              throw new Error(
+                `Failed to generate signed URL in GCP environment: ${error.message}\n` +
+                  'This may indicate that your GCP service account lacks the necessary permissions for URL signing. ' +
+                  'Please ensure your service account has the "Storage Object Admin" or "Storage Admin" role.'
+              );
+            }
+
+            // Fallback error for other cases
             throw new Error(
               `Failed to generate signed URL: ${error.message}\n` +
                 'This usually means your service account credentials are incomplete. ' +
                 'Please ensure your serviceAccount configuration includes both client_email and private_key fields.'
             );
           }
+
+          // Re-throw other errors as-is
           throw error;
         }
       },
+
+      detectGCPEnvironment() {
+        // Check common GCP environment variables
+        const gcpEnvVars = [
+          'GOOGLE_CLOUD_PROJECT',
+          'GCLOUD_PROJECT',
+          'GAE_APPLICATION', // App Engine
+          'GAE_SERVICE', // App Engine
+          'K_SERVICE', // Cloud Run
+          'FUNCTION_NAME', // Cloud Functions
+          'FUNCTION_TARGET', // Cloud Functions
+        ];
+
+        // Check if we're running in a GCP environment
+        const hasGCPEnvVar = gcpEnvVars.some((envVar) => process.env[envVar]);
+
+        // Additional check for Google metadata server (available in GCP environments)
+        const hasGoogleMetadata =
+          process.env.GCE_METADATA_HOST || process.env.KUBERNETES_SERVICE_HOST; // GKE
+
+        return hasGCPEnvVar || !!hasGoogleMetadata;
+      },
     };
   },
 };



Test patch:
diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts
index 6d845b4..17a3830 100644
--- a/src/__tests__/index.test.ts
+++ b/src/__tests__/index.test.ts
@@ -646,8 +646,15 @@ describe('Provider', () => {
         basePath: 'base/path',
       };
 
+      // Mock a signing error to simulate ADC not working in non-GCP environment
+      const signingError = new Error('Cannot sign data without client_email');
+      mockedFile.getSignedUrl = jest.fn().mockRejectedValue(signingError);
+
       const providerInstance = provider.init(configWithoutServiceAccount);
 
+      // Mock detectGCPEnvironment to return false (non-GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(false);
+
       await expect(providerInstance.getSignedUrl(mockedFileData)).rejects.toThrow(
         'Cannot generate signed URLs without service account credentials. ' +
           'Either:\n' +
@@ -656,9 +663,14 @@ describe('Provider', () => {
           'For more information, see: https://github.com/strapi-community/strapi-provider-upload-google-cloud-storage#setting-up-google-authentication'
       );
 
-      expect(mockedStorage.bucket).not.toHaveBeenCalled();
-      expect(mockedBucket.file).not.toHaveBeenCalled();
-      expect(mockedFile.getSignedUrl).not.toHaveBeenCalled();
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
+
+      // Restore mock
+      mockedFile.getSignedUrl = jest
+        .fn()
+        .mockReturnValue(['https://storage.googleapis.com/my-bucket/o/people-working.png']);
     });
 
     test('Returns direct URL when no service account but public files', async () => {
@@ -672,9 +684,17 @@ describe('Provider', () => {
         basePath: 'base/path',
       };
 
+      // Mock a signing error to simulate ADC not working in non-GCP environment
+      const signingError = new Error('Cannot sign data without client_email');
+      mockedFile.getSignedUrl = jest.fn().mockRejectedValue(signingError);
+
       const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
 
       const providerInstance = provider.init(configWithoutServiceAccount);
+
+      // Mock detectGCPEnvironment to return false (non-GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(false);
+
       const result = await providerInstance.getSignedUrl(mockedFileData);
 
       expect(result).toEqual({
@@ -686,11 +706,16 @@ describe('Provider', () => {
           'Returning direct URL instead. This works only for public files.'
       );
 
-      expect(mockedStorage.bucket).not.toHaveBeenCalled();
-      expect(mockedBucket.file).not.toHaveBeenCalled();
-      expect(mockedFile.getSignedUrl).not.toHaveBeenCalled();
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
 
       consoleWarnSpy.mockRestore();
+
+      // Restore mock
+      mockedFile.getSignedUrl = jest
+        .fn()
+        .mockReturnValue(['https://storage.googleapis.com/my-bucket/o/people-working.png']);
     });
 
     test('Throws error when service account lacks client_email and private files', async () => {
@@ -708,15 +733,27 @@ describe('Provider', () => {
         },
       };
 
+      // Mock a signing error to simulate incomplete credentials
+      const signingError = new Error('Cannot sign data without client_email');
+      mockedFile.getSignedUrl = jest.fn().mockRejectedValue(signingError);
+
       const providerInstance = provider.init(configWithIncompleteServiceAccount);
 
+      // Mock detectGCPEnvironment to return false (non-GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(false);
+
       await expect(providerInstance.getSignedUrl(mockedFileData)).rejects.toThrow(
         'Cannot generate signed URLs without service account credentials'
       );
 
-      expect(mockedStorage.bucket).not.toHaveBeenCalled();
-      expect(mockedBucket.file).not.toHaveBeenCalled();
-      expect(mockedFile.getSignedUrl).not.toHaveBeenCalled();
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
+
+      // Restore mock
+      mockedFile.getSignedUrl = jest
+        .fn()
+        .mockReturnValue(['https://storage.googleapis.com/my-bucket/o/people-working.png']);
     });
 
     test('Returns direct URL when service account lacks client_email but public files', async () => {
@@ -735,9 +772,17 @@ describe('Provider', () => {
         },
       };
 
+      // Mock a signing error to simulate incomplete credentials
+      const signingError = new Error('Cannot sign data without client_email');
+      mockedFile.getSignedUrl = jest.fn().mockRejectedValue(signingError);
+
       const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
 
       const providerInstance = provider.init(configWithIncompleteServiceAccount);
+
+      // Mock detectGCPEnvironment to return false (non-GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(false);
+
       const result = await providerInstance.getSignedUrl(mockedFileData);
 
       expect(result).toEqual({
@@ -749,11 +794,110 @@ describe('Provider', () => {
           'Returning direct URL instead. This works only for public files.'
       );
 
-      expect(mockedStorage.bucket).not.toHaveBeenCalled();
-      expect(mockedBucket.file).not.toHaveBeenCalled();
-      expect(mockedFile.getSignedUrl).not.toHaveBeenCalled();
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
 
       consoleWarnSpy.mockRestore();
+
+      // Restore mock
+      mockedFile.getSignedUrl = jest
+        .fn()
+        .mockReturnValue(['https://storage.googleapis.com/my-bucket/o/people-working.png']);
+    });
+
+    test('Works with ADC in GCP environment without explicit service account', async () => {
+      mockedFileData.url = 'base/path/tmp/strapi/4l0ngH45h.jpeg';
+
+      const configWithoutServiceAccount = {
+        bucketName: 'my-bucket',
+        publicFiles: false,
+        baseUrl: 'https://storage.googleapis.com/{bucket-name}',
+        basePath: 'base/path',
+      };
+
+      const providerInstance = provider.init(configWithoutServiceAccount);
+
+      // Mock detectGCPEnvironment to return true (GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(true);
+
+      const result = await providerInstance.getSignedUrl(mockedFileData);
+
+      expect(result).toEqual({
+        url: 'https://storage.googleapis.com/my-bucket/o/people-working.png',
+      });
+
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
+    });
+
+    test('Throws specific error for GCP environment when ADC fails', async () => {
+      mockedFileData.url = 'base/path/tmp/strapi/4l0ngH45h.jpeg';
+
+      const configWithoutServiceAccount = {
+        bucketName: 'my-bucket',
+        publicFiles: false,
+        baseUrl: 'https://storage.googleapis.com/{bucket-name}',
+        basePath: 'base/path',
+      };
+
+      // Mock a signing error to simulate ADC failure in GCP environment
+      const signingError = new Error('Cannot sign data without client_email');
+      mockedFile.getSignedUrl = jest.fn().mockRejectedValue(signingError);
+
+      const providerInstance = provider.init(configWithoutServiceAccount);
+
+      // Mock detectGCPEnvironment to return true (GCP environment)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(true);
+
+      await expect(providerInstance.getSignedUrl(mockedFileData)).rejects.toThrow(
+        'Failed to generate signed URL in GCP environment: Cannot sign data without client_email\n' +
+          'This may indicate that your GCP service account lacks the necessary permissions for URL signing. ' +
+          'Please ensure your service account has the "Storage Object Admin" or "Storage Admin" role.'
+      );
+
+      expect(mockedStorage.bucket).toHaveBeenCalled();
+      expect(mockedBucket.file).toHaveBeenCalled();
+      expect(mockedFile.getSignedUrl).toHaveBeenCalled();
+
+      // Restore mock
+      mockedFile.getSignedUrl = jest
+        .fn()
+        .mockReturnValue(['https://storage.googleapis.com/my-bucket/o/people-working.png']);
+    });
+
+    test('Detects GCP environment correctly', () => {
+      const providerInstance = provider.init(mockedConfig);
+
+      // Test with GCP environment variables
+      const originalEnv = process.env;
+      process.env = { ...originalEnv, GOOGLE_CLOUD_PROJECT: 'test-project' };
+
+      expect(providerInstance.detectGCPEnvironment()).toBe(true);
+
+      process.env = { ...originalEnv, GAE_APPLICATION: 'test-app' };
+      expect(providerInstance.detectGCPEnvironment()).toBe(true);
+
+      process.env = { ...originalEnv, K_SERVICE: 'test-service' };
+      expect(providerInstance.detectGCPEnvironment()).toBe(true);
+
+      process.env = { ...originalEnv, FUNCTION_NAME: 'test-function' };
+      expect(providerInstance.detectGCPEnvironment()).toBe(true);
+
+      // Test without GCP environment variables
+      process.env = { ...originalEnv };
+      delete process.env.GOOGLE_CLOUD_PROJECT;
+      delete process.env.GAE_APPLICATION;
+      delete process.env.K_SERVICE;
+      delete process.env.FUNCTION_NAME;
+      delete process.env.GCE_METADATA_HOST;
+      delete process.env.KUBERNETES_SERVICE_HOST;
+
+      expect(providerInstance.detectGCPEnvironment()).toBe(false);
+
+      // Restore environment
+      process.env = originalEnv;
     });
 
     test('Handles signing errors with helpful message', async () => {
@@ -764,6 +908,9 @@ describe('Provider', () => {
 
       const providerInstance = provider.init(mockedConfig);
 
+      // Mock detectGCPEnvironment to return false (non-GCP environment with explicit service account)
+      providerInstance.detectGCPEnvironment = jest.fn().mockReturnValue(false);
+
       await expect(providerInstance.getSignedUrl(mockedFileData)).rejects.toThrow(
         'Failed to generate signed URL: Cannot sign data without client_email\n' +
           'This usually means your service account credentials are incomplete. ' +