Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions google-cloud-storage/acceptance/storage/signed_url_v2_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@
end

describe Google::Cloud::Storage::Bucket, :signed_url do
it "should create a signed read url automatically using IAM API when on GCE/Workload Identity" do
local_file = File.new files[:logo][:path]
file = bucket.create_file local_file, "CloudLogoSignedUrlGetBucketAuto.png"

issuer = bucket.service.credentials.issuer
skip "Test requires a service account with an issuer" unless issuer

bucket.service.credentials.stub :signing_key, nil do
bucket.service.credentials.stub :issuer, nil do
Google::Cloud.env.stub :metadata?, true do
Google::Cloud.env.stub :lookup_metadata, issuer do
five_min_from_now = 5 * 60
url = bucket.signed_url file.name,
method: "GET",
expires: five_min_from_now

uri = URI url
http = Net::HTTP.new uri.host, uri.port
http.use_ssl = true
http.ca_file ||= ENV["SSL_CERT_FILE"] if ENV["SSL_CERT_FILE"]

resp = http.get uri.request_uri
_(resp.code).must_equal "200"

Tempfile.open ["google-cloud", ".png"] do |tmpfile|
tmpfile.binmode
tmpfile.write resp.body
_(tmpfile.size).must_equal local_file.size
end
end
end
end
end
end

it "should create a signed read url with space in file name" do
local_file = File.new files[:logo][:path]
file = bucket.create_file local_file, "CloudLogoSignedUrl GetBucket.png"
Expand Down
36 changes: 36 additions & 0 deletions google-cloud-storage/acceptance/storage/signed_url_v4_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@
end

describe Google::Cloud::Storage::Bucket, :signed_url do
it "should create a signed read url v4 automatically using IAM API when on GCE/Workload Identity" do
local_file = File.new files[:logo][:path]
file = bucket.create_file local_file, "CloudLogoSignedUrlGetBucketAuto.png"

issuer = bucket.service.credentials.issuer
skip "Test requires a service account with an issuer" unless issuer

bucket.service.credentials.stub :signing_key, nil do
bucket.service.credentials.stub :issuer, nil do
Google::Cloud.env.stub :metadata?, true do
Google::Cloud.env.stub :lookup_metadata, issuer do
five_min_from_now = 5 * 60
url = bucket.signed_url file.name,
method: "GET",
expires: five_min_from_now,
version: :v4

uri = URI url
http = Net::HTTP.new uri.host, uri.port
http.use_ssl = true
http.ca_file ||= ENV["SSL_CERT_FILE"] if ENV["SSL_CERT_FILE"]

resp = http.get uri.request_uri
_(resp.code).must_equal "200"

Tempfile.open ["google-cloud", ".png"] do |tmpfile|
tmpfile.binmode
tmpfile.write resp.body
_(tmpfile.size).must_equal local_file.size
end
end
end
end
end
end

it "should create a signed read url version v4 with space in file name" do
local_file = File.new files[:logo][:path]
file = bucket.create_file local_file, "CloudLogoSignedUrl GetBucket.png"
Expand Down
20 changes: 13 additions & 7 deletions google-cloud-storage/lib/google/cloud/storage/bucket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2261,6 +2261,10 @@ def compose sources,
# steps in [Service Account Authentication](
# https://cloud.google.com/iam/docs/service-accounts).
#
# When running in Google Cloud environments (like Compute Engine, Cloud Run, or
# Kubernetes Engine), the library automatically detects the environment and uses
# the IAM Credentials API to sign the URL. A local private key is not required.
#
# @see https://cloud.google.com/storage/docs/access-control/signed-urls
# Signed URLs guide
# @see https://cloud.google.com/storage/docs/access-control/signed-urls#signing-resumable
Expand All @@ -2284,7 +2288,12 @@ def compose sources,
# @param [Hash] headers Google extension headers (custom HTTP headers
# that begin with `x-goog-`) that must be included in requests that
# use the signed URL.
# @param [String] issuer Service Account's Client Email.
# @param [String] issuer Service Account's Client Email. If not provided, the
# library will attempt to extract it from the standard credentials. If running
# in a Google Cloud environment (like GCE or GKE), the library will automatically
# fetch the default service account email from the metadata server. For external
# Workload Identity Federation (e.g., GitHub Actions or AWS), you must provide this
# value explicitly to use keyless signing.
# @param [String] client_email Service Account's Client Email.
# @param [OpenSSL::PKey::RSA, String, Proc] signing_key Service Account's
# Private Key or a Proc that accepts a single String parameter and returns a
Expand All @@ -2296,12 +2305,9 @@ def compose sources,
# Private Key or a Proc that accepts a single String parameter and returns a
# RSA SHA256 signature using a valid Google Service Account Private Key.
#
# When using this method in environments such as GAE Flexible Environment,
# GKE, or Cloud Functions where the private key is unavailable, it may be
# necessary to provide a Proc (or lambda) via the signer parameter. This
# Proc should return a signature created using a RPC call to the
# [Service Account Credentials signBlob](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob)
# method as shown in the example below.
# A custom proc used to sign the URL. This is no longer required for Workload
# Identity or keyless environments, as the library will automatically fallback to
# the IAM Credentials API if a private key is missing but an `issuer` is available.
# @param [Hash] query Query string parameters to include in the signed
# URL. The given parameters are not verified by the signature.
#
Expand Down
20 changes: 13 additions & 7 deletions google-cloud-storage/lib/google/cloud/storage/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,10 @@ def public_url protocol: :https
# steps in [Service Account Authentication](
# https://cloud.google.com/iam/docs/service-accounts).
#
# When running in Google Cloud environments (like Compute Engine, Cloud Run, or
# Kubernetes Engine), the library automatically detects the environment and uses
# the IAM Credentials API to sign the URL. A local private key is not required.
#
# @see https://cloud.google.com/storage/docs/access-control/signed-urls
# Signed URLs guide
# @see https://cloud.google.com/storage/docs/access-control/signed-urls#signing-resumable
Expand All @@ -1762,7 +1766,12 @@ def public_url protocol: :https
# @param [Hash] headers Google extension headers (custom HTTP headers
# that begin with `x-goog-`) that must be included in requests that
# use the signed URL.
# @param [String] issuer Service Account's Client Email.
# @param [String] issuer Service Account's Client Email. If not provided, the
# library will attempt to extract it from the standard credentials. If running
# in a Google Cloud environment (like GCE or GKE), the library will automatically
# fetch the default service account email from the metadata server. For external
# Workload Identity Federation (e.g., GitHub Actions or AWS), you must provide this
# value explicitly to use keyless signing.
# @param [String] client_email Service Account's Client Email.
# @param [OpenSSL::PKey::RSA, String, Proc] signing_key Service Account's
# Private Key or a Proc that accepts a single String parameter and returns a
Expand All @@ -1774,12 +1783,9 @@ def public_url protocol: :https
# Private Key or a Proc that accepts a single String parameter and returns a
# RSA SHA256 signature using a valid Google Service Account Private Key.
#
# When using this method in environments such as GAE Flexible Environment,
# GKE, or Cloud Functions where the private key is unavailable, it may be
# necessary to provide a Proc (or lambda) via the signer parameter. This
# Proc should return a signature created using a RPC call to the
# [Service Account Credentials signBlob](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob)
# method as shown in the example below.
# A custom proc used to sign the URL. This is no longer required for Workload
# Identity or keyless environments, as the library will automatically fallback to
# the IAM Credentials API if a private key is missing but an `issuer` is available.
# @param [Hash] query Query string parameters to include in the signed
# URL. The given parameters are not verified by the signature.
#
Expand Down
38 changes: 25 additions & 13 deletions google-cloud-storage/lib/google/cloud/storage/file/signer_v2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,15 @@ def signature_str options
end

def determine_signing_key options = {}
signing_key = options[:signing_key] || options[:private_key] ||
options[:signer] || @service.credentials.signing_key
raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
signing_key
options[:signing_key] || options[:private_key] ||
options[:signer] || @service.credentials.signing_key
end

def determine_issuer options = {}
issuer = options[:issuer] || options[:client_email] || @service.credentials.issuer
if issuer.nil? && Google::Cloud.env.metadata?
issuer = Google::Cloud.env.lookup_metadata "instance", "service-accounts/default/email"
end
raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
issuer
end
Expand All @@ -111,7 +112,7 @@ def post_object options
policy_str = p.to_json
policy = Base64.strict_encode64(policy_str).delete "\n"

signature = generate_signature s, policy
signature = generate_signature i, s, policy

fields[:GoogleAccessId] = i
fields[:signature] = signature
Expand All @@ -126,19 +127,23 @@ def signed_url options
i = determine_issuer options
s = determine_signing_key options

sig = generate_signature s, signature_str(options)
sig = generate_signature i, s, signature_str(options)
generate_signed_url i, sig, options[:expires], options[:query]
end

def generate_signature signing_key, secret
def generate_signature issuer, signing_key, secret
unencoded_signature = ""
if signing_key.is_a? Proc
unencoded_signature = signing_key.call secret
else
unless signing_key.respond_to? :sign
signing_key = OpenSSL::PKey::RSA.new signing_key
if signing_key
if signing_key.is_a? Proc
unencoded_signature = signing_key.call secret
else
unless signing_key.respond_to? :sign
signing_key = OpenSSL::PKey::RSA.new signing_key
end
unencoded_signature = signing_key.sign OpenSSL::Digest::SHA256.new, secret
end
unencoded_signature = signing_key.sign OpenSSL::Digest::SHA256.new, secret
else
unencoded_signature = iam_signer_instance.sign issuer, secret
end
Base64.strict_encode64(unencoded_signature).delete "\n"
end
Expand Down Expand Up @@ -168,6 +173,13 @@ def format_extension_headers headers
def url_escape str
CGI.escape String str
end

def iam_signer_instance
@iam_signer_instance ||= begin
require "google/cloud/storage/iam_signer"
Google::Cloud::Storage::IAMSigner.new(@service.credentials)
end
end
end
end
end
Expand Down
43 changes: 31 additions & 12 deletions google-cloud-storage/lib/google/cloud/storage/file/signer_v4.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def post_object issuer: nil,
policy_str = escape_characters p.to_json

policy = Base64.strict_encode64(policy_str).force_encoding "utf-8"
signature = generate_signature s, policy
signature = generate_signature i, s, policy

post_fields["x-goog-signature"] = signature
post_fields["policy"] = policy
Expand Down Expand Up @@ -195,14 +195,15 @@ def signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
def determine_issuer issuer, client_email
# Parse the Service Account and get client id and private key
issuer = issuer || client_email || @service.credentials.issuer
if issuer.nil? && Google::Cloud.env.metadata?
issuer = Google::Cloud.env.lookup_metadata "instance", "service-accounts/default/email"
end
raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
issuer
end

def determine_signing_key signing_key, private_key, signer
signing_key = signing_key || private_key || signer || @service.credentials.signing_key
raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
signing_key
signing_key || private_key || signer || @service.credentials.signing_key
end

def error_msg attr_name
Expand All @@ -229,7 +230,14 @@ def service_account_signer signer
def issuer_and_signer issuer, client_email, signing_key, private_key, signer
issuer = determine_issuer issuer, client_email
signing_key = determine_signing_key signing_key, private_key, signer
signer = service_account_signer signing_key
if signing_key
signer = service_account_signer signing_key
else
signer = lambda do |string_to_sign|
sig = iam_signer_instance.sign issuer, string_to_sign
sig.unpack1 "H*"
end
end
[issuer, signer]
end

Expand Down Expand Up @@ -351,18 +359,29 @@ def post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
end
end

def generate_signature signing_key, data
def generate_signature issuer, signing_key, data
packed_signature = nil
if signing_key.is_a? Proc
packed_signature = signing_key.call data
else
unless signing_key.respond_to? :sign
signing_key = OpenSSL::PKey::RSA.new signing_key
if signing_key
if signing_key.is_a? Proc
packed_signature = signing_key.call data
else
unless signing_key.respond_to? :sign
signing_key = OpenSSL::PKey::RSA.new signing_key
end
packed_signature = signing_key.sign OpenSSL::Digest::SHA256.new, data
end
packed_signature = signing_key.sign OpenSSL::Digest::SHA256.new, data
else
packed_signature = iam_signer_instance.sign issuer, data
end
packed_signature.unpack1("H*").force_encoding "utf-8"
end

def iam_signer_instance
@iam_signer_instance ||= begin
require "google/cloud/storage/iam_signer"
Google::Cloud::Storage::IAMSigner.new(@service.credentials)
end
end
end
end
end
Expand Down
49 changes: 49 additions & 0 deletions google-cloud-storage/lib/google/cloud/storage/iam_signer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "googleauth"

module Google
module Cloud
module Storage
##
# @private
# Helper class for signing blobs via the IAM Credentials API.
class IAMSigner
def initialize credentials
require "google/apis/iamcredentials_v1"

@client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new
@client.authorization = credentials.client
end

def sign issuer, string_to_sign
request = Google::Apis::IamcredentialsV1::SignBlobRequest.new(
payload: string_to_sign
)
resource = "projects/-/serviceAccounts/#{issuer}"

begin
response = @client.sign_service_account_blob resource, request
response.signed_blob
rescue Google::Apis::Error => e
raise Google::Cloud::Storage::SignedUrlUnavailable, "Failed to sign URL via IAM Credentials API. Ensure the Workload Identity service account has the 'Service Account Token Creator' role. Underlying error: #{e.message}"
end
end
end
end
end
end
Loading
Loading