firebase-admin-sdk 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +38 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +52 -0
- data/Rakefile +10 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/firebase-admin-sdk.gemspec +36 -0
- data/lib/firebase-admin-sdk.rb +17 -0
- data/lib/firebase/admin/app.rb +34 -0
- data/lib/firebase/admin/auth/certificates_fetcher.rb +62 -0
- data/lib/firebase/admin/auth/client.rb +116 -0
- data/lib/firebase/admin/auth/error.rb +23 -0
- data/lib/firebase/admin/auth/token_verifier.rb +114 -0
- data/lib/firebase/admin/auth/user_info.rb +60 -0
- data/lib/firebase/admin/auth/user_manager.rb +92 -0
- data/lib/firebase/admin/auth/user_record.rb +70 -0
- data/lib/firebase/admin/auth/utils.rb +93 -0
- data/lib/firebase/admin/config.rb +57 -0
- data/lib/firebase/admin/credentials.rb +79 -0
- data/lib/firebase/admin/error.rb +9 -0
- data/lib/firebase/admin/gce.rb +42 -0
- data/lib/firebase/admin/internal/http_client.rb +78 -0
- data/lib/firebase/admin/version.rb +7 -0
- metadata +230 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
module Firebase
|
2
|
+
module Admin
|
3
|
+
module Auth
|
4
|
+
# A base class for errors raised by the admin sdk auth client.
|
5
|
+
class Error < Firebase::Admin::Error; end
|
6
|
+
|
7
|
+
# Raised when a request for certificates fails.
|
8
|
+
class CertificateRequestError < Error; end
|
9
|
+
|
10
|
+
# Raised when certificate is invalid.
|
11
|
+
class InvalidCertificateError < Error; end
|
12
|
+
|
13
|
+
# Raised when id token verification fails.
|
14
|
+
class InvalidTokenError < Error; end
|
15
|
+
|
16
|
+
# Raised when id token verification fails because the token is expired.
|
17
|
+
class ExpiredTokenError < Error; end
|
18
|
+
|
19
|
+
# Raised when a user cannot be created.
|
20
|
+
class CreateUserError < Error; end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require "jwt"
|
2
|
+
require "openssl/x509"
|
3
|
+
|
4
|
+
module Firebase
|
5
|
+
module Admin
|
6
|
+
module Auth
|
7
|
+
# Base class for verifying Firebase JWTs.
|
8
|
+
class JWTVerifier
|
9
|
+
# Initializes a new verifier.
|
10
|
+
#
|
11
|
+
# @param [Firebase::Admin::App] app
|
12
|
+
# The Firebase app to verify tokens for.
|
13
|
+
# @param [String] certificates_url
|
14
|
+
# The url to load public key certificates used during token verification.
|
15
|
+
def initialize(app, certificates_url)
|
16
|
+
@project_id = app.project_id
|
17
|
+
@certificates = CertificatesFetcher.new(certificates_url)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Verifies a Firebase ID token.
|
21
|
+
#
|
22
|
+
# @param [String] token A Firebase JWT ID token.
|
23
|
+
# @param [Boolean] is_emulator skips signature verification if true.
|
24
|
+
# @return [Hash] the verified claims.
|
25
|
+
def verify(token, is_emulator: false)
|
26
|
+
payload = decode(token, is_emulator).first
|
27
|
+
sub = payload["sub"]
|
28
|
+
raise JWT::InvalidSubError, "Invalid subject." unless sub.is_a?(String) && !sub.empty?
|
29
|
+
payload["uid"] = sub
|
30
|
+
payload
|
31
|
+
rescue JWT::ExpiredSignature => e
|
32
|
+
raise expired_error, e.message
|
33
|
+
rescue JWT::DecodeError => e
|
34
|
+
raise invalid_error, e.message
|
35
|
+
end
|
36
|
+
|
37
|
+
# Override in subclasses to set the issuer
|
38
|
+
def issuer
|
39
|
+
raise NotImplementedError
|
40
|
+
end
|
41
|
+
|
42
|
+
def invalid_error
|
43
|
+
raise NotImplementedError
|
44
|
+
end
|
45
|
+
|
46
|
+
def expired_error
|
47
|
+
raise NotImplementedError
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def decode_options
|
53
|
+
{
|
54
|
+
iss: issuer,
|
55
|
+
aud: @project_id,
|
56
|
+
algorithm: "RS256",
|
57
|
+
verify_iat: true,
|
58
|
+
verify_iss: true,
|
59
|
+
verify_aud: true
|
60
|
+
}
|
61
|
+
end
|
62
|
+
|
63
|
+
def find_key(header)
|
64
|
+
return nil unless header["kid"].is_a?(String)
|
65
|
+
certificate = @certificates.fetch_certificates![header["kid"]]
|
66
|
+
OpenSSL::X509::Certificate.new(certificate).public_key unless certificate.nil?
|
67
|
+
rescue OpenSSL::X509::CertificateError => e
|
68
|
+
raise InvalidCertificateError, e.message
|
69
|
+
end
|
70
|
+
|
71
|
+
def decode(token, is_emulator)
|
72
|
+
return decode_unsigned(token) if is_emulator
|
73
|
+
JWT.decode(token, nil, true, decode_options) do |header|
|
74
|
+
find_key(header)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def decode_unsigned(token)
|
79
|
+
raise InvalidTokenError, "token must not be nil" unless token
|
80
|
+
raise InvalidTokenError, "token must be a string" unless token.is_a?(String)
|
81
|
+
raise InvalidTokenError, "The auth emulator only accepts unsigned ID tokens." if token.split(".").length == 3
|
82
|
+
options = decode_options.merge({algorithm: "none"})
|
83
|
+
JWT.decode(token, nil, false, options)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Verifier for Firebase ID tokens.
|
88
|
+
class IDTokenVerifier < JWTVerifier
|
89
|
+
CERTIFICATES_URI =
|
90
|
+
"https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
|
91
|
+
|
92
|
+
# Initializes a new [IDTokenVerifier].
|
93
|
+
#
|
94
|
+
# @param [Firebase::Admin::App] app
|
95
|
+
# The Firebase app to verify tokens for.
|
96
|
+
def initialize(app)
|
97
|
+
super(app, CERTIFICATES_URI)
|
98
|
+
end
|
99
|
+
|
100
|
+
def issuer
|
101
|
+
"https://securetoken.google.com/#{@project_id}"
|
102
|
+
end
|
103
|
+
|
104
|
+
def invalid_error
|
105
|
+
InvalidTokenError
|
106
|
+
end
|
107
|
+
|
108
|
+
def expired_error
|
109
|
+
ExpiredTokenError
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Firebase
|
4
|
+
module Admin
|
5
|
+
module Auth
|
6
|
+
# Standard profile information for a user.
|
7
|
+
#
|
8
|
+
# Also used to expose profile information returned by an identity provider.
|
9
|
+
class UserInfo
|
10
|
+
# Constructs a new UserInfo.
|
11
|
+
#
|
12
|
+
# @param [Hash] data
|
13
|
+
# A hash of profile information
|
14
|
+
def initialize(data)
|
15
|
+
@data = data || {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# Gets the ID of this user.
|
19
|
+
def uid
|
20
|
+
@data["rawId"]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Gets the display name of this user.
|
24
|
+
def display_name
|
25
|
+
@data["displayName"]
|
26
|
+
end
|
27
|
+
|
28
|
+
# Gets the email address associated with this user.
|
29
|
+
def email
|
30
|
+
@data["email"]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Gets the phone number associated with this user.
|
34
|
+
def phone_number
|
35
|
+
@data["phoneNumber"]
|
36
|
+
end
|
37
|
+
|
38
|
+
# Gets the photo url of this user.
|
39
|
+
def photo_url
|
40
|
+
@data["photoUrl"]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Gets the id of the identity provider.
|
44
|
+
#
|
45
|
+
# This can be a short domain name (e.g. google.com), or the identity of an OpenID
|
46
|
+
# identity provider.
|
47
|
+
def provider_id
|
48
|
+
@data["providerId"]
|
49
|
+
end
|
50
|
+
|
51
|
+
# Converts the object into a hash.
|
52
|
+
#
|
53
|
+
# @return [Hash]
|
54
|
+
def to_h
|
55
|
+
@data.dup
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Firebase
|
2
|
+
module Admin
|
3
|
+
module Auth
|
4
|
+
# Base url for the Google Identity Toolkit
|
5
|
+
ID_TOOLKIT_URL = "https://identitytoolkit.googleapis.com/v1"
|
6
|
+
|
7
|
+
# Provides methods for interacting with the Google Identity Toolkit
|
8
|
+
class UserManager
|
9
|
+
# Initializes a UserManager.
|
10
|
+
#
|
11
|
+
# @param [String] project_id The Firebase project id.
|
12
|
+
# @param [Credentials] credentials The credentials to authenticate with.
|
13
|
+
# @param [String, nil] url_override The base url to override with.
|
14
|
+
def initialize(project_id, credentials, url_override = nil)
|
15
|
+
uri = "#{url_override || ID_TOOLKIT_URL}/"
|
16
|
+
@project_id = project_id
|
17
|
+
@client = Firebase::Admin::Internal::HTTPClient.new(uri: uri, credentials: credentials)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Creates a new user account with the specified properties.
|
21
|
+
#
|
22
|
+
# @param [String, nil] uid The id to assign to the newly created user.
|
23
|
+
# @param [String, nil] display_name The user’s display name.
|
24
|
+
# @param [String, nil] email The user’s primary email.
|
25
|
+
# @param [Boolean, nil] email_verified A boolean indicating whether or not the user’s primary email is verified.
|
26
|
+
# @param [String, nil] phone_number The user’s primary phone number.
|
27
|
+
# @param [String, nil] photo_url The user’s photo URL.
|
28
|
+
# @param [String, nil] password The user’s raw, unhashed password.
|
29
|
+
# @param [Boolean, nil] disabled A boolean indicating whether or not the user account is disabled.
|
30
|
+
#
|
31
|
+
# @raise [CreateUserError] if a user cannot be created.
|
32
|
+
#
|
33
|
+
# @return [UserRecord]
|
34
|
+
def create_user(uid: nil, display_name: nil, email: nil, email_verified: nil, phone_number: nil, photo_url: nil, password: nil, disabled: nil)
|
35
|
+
payload = {
|
36
|
+
localId: validate_uid(uid),
|
37
|
+
displayName: validate_display_name(display_name),
|
38
|
+
email: validate_email(email),
|
39
|
+
phoneNumber: validate_phone_number(phone_number),
|
40
|
+
photoUrl: validate_photo_url(photo_url),
|
41
|
+
password: validate_password(password),
|
42
|
+
emailVerified: to_boolean(email_verified),
|
43
|
+
disabled: to_boolean(disabled)
|
44
|
+
}.compact
|
45
|
+
res = @client.post(with_path("accounts"), payload).body
|
46
|
+
uid = res&.fetch("localId")
|
47
|
+
raise CreateUserError, "failed to create user #{res}" if uid.nil?
|
48
|
+
get_user_by(uid: uid)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Gets the user corresponding to the provided key
|
52
|
+
#
|
53
|
+
# @param [Hash] query Query parameters to search for a user by.
|
54
|
+
# @option query [String] :uid A user id.
|
55
|
+
# @option query [String] :email An email address.
|
56
|
+
# @option query [String] :phone_number A phone number.
|
57
|
+
#
|
58
|
+
# @return [UserRecord] A user or nil if not found
|
59
|
+
def get_user_by(query)
|
60
|
+
if (uid = query[:uid])
|
61
|
+
payload = {localId: Array(validate_uid(uid, required: true))}
|
62
|
+
elsif (email = query[:email])
|
63
|
+
payload = {email: Array(validate_email(email, required: true))}
|
64
|
+
elsif (phone_number = query[:phone_number])
|
65
|
+
payload = {phoneNumber: Array(validate_phone_number(phone_number, required: true))}
|
66
|
+
else
|
67
|
+
raise ArgumentError, "Unsupported query: #{query}"
|
68
|
+
end
|
69
|
+
res = @client.post(with_path("accounts:lookup"), payload).body
|
70
|
+
users = res["users"] if res
|
71
|
+
UserRecord.new(users[0]) if users.is_a?(Array) && users.length > 0
|
72
|
+
end
|
73
|
+
|
74
|
+
# Deletes the user corresponding to the specified user id.
|
75
|
+
#
|
76
|
+
# @param [String] uid
|
77
|
+
# The id of the user.
|
78
|
+
def delete_user(uid)
|
79
|
+
@client.post(with_path("accounts:delete"), {localId: validate_uid(uid, required: true)})
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def with_path(path)
|
85
|
+
"projects/#{@project_id}/#{path}"
|
86
|
+
end
|
87
|
+
|
88
|
+
include Utils
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Firebase
|
4
|
+
module Admin
|
5
|
+
module Auth
|
6
|
+
# A Firebase User account
|
7
|
+
class UserRecord < UserInfo
|
8
|
+
# Gets the ID of this user.
|
9
|
+
def uid
|
10
|
+
@data["localId"]
|
11
|
+
end
|
12
|
+
|
13
|
+
# Gets the id of the identity provider.
|
14
|
+
#
|
15
|
+
# Always firebase for user accounts.
|
16
|
+
def provider_id
|
17
|
+
"firebase"
|
18
|
+
end
|
19
|
+
|
20
|
+
def email_verified?
|
21
|
+
!!@data["emailVerified"]
|
22
|
+
end
|
23
|
+
|
24
|
+
def disabled?
|
25
|
+
!!@data["disabled"]
|
26
|
+
end
|
27
|
+
|
28
|
+
# Gets the time, in milliseconds since the epoch, before which tokens are invalid.
|
29
|
+
#
|
30
|
+
# @note truncated to 1 second accuracy.
|
31
|
+
#
|
32
|
+
# @return [Numeric]
|
33
|
+
# Timestamp in milliseconds since the epoch, truncated to the second.
|
34
|
+
# All tokens issued before that time are considered revoked.
|
35
|
+
def tokens_valid_after_timestamp
|
36
|
+
raise NotImplementedError
|
37
|
+
end
|
38
|
+
|
39
|
+
# Gets additional metadata associated with this user.
|
40
|
+
#
|
41
|
+
# @return [UserMetadata]
|
42
|
+
def user_metadata
|
43
|
+
raise NotImplementedError
|
44
|
+
end
|
45
|
+
|
46
|
+
# Gets a list of (UserInfo) instances.
|
47
|
+
#
|
48
|
+
# Each object represents an identity from an identity provider that is linked to this user.
|
49
|
+
#
|
50
|
+
# @return [Array of UserInfo]
|
51
|
+
def provider_data
|
52
|
+
providers = @data["providerUserInfo"] || []
|
53
|
+
providers.to_a.map { |p| UserInfo.new(p) }
|
54
|
+
end
|
55
|
+
|
56
|
+
# Gets any custom claims set on this user account.
|
57
|
+
def custom_claims
|
58
|
+
claims = @data["customAttributes"]
|
59
|
+
parsed = JSON.parse(claims) unless claims.nil?
|
60
|
+
parsed if parsed.is_a?(Hash) && !parsed.empty?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns the tenant ID of this user.
|
64
|
+
def tenant_id
|
65
|
+
raise NotImplementedError
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Firebase
|
2
|
+
module Admin
|
3
|
+
module Auth
|
4
|
+
module Utils
|
5
|
+
AUTH_EMULATOR_HOST_VAR = "FIREBASE_AUTH_EMULATOR_HOST"
|
6
|
+
|
7
|
+
INVALID_CHARS_PATTERN = /[^a-z0-9:\/?#\[\\\]@!$&'()*+,;=.\-_~%]/i
|
8
|
+
HOSTNAME_PATTERN = /^[a-zA-Z0-9]+[\w-]*([.]?[a-zA-Z0-9]+[\w-]*)*$/
|
9
|
+
PATHNAME_PATTERN = /^(\/[\w\-.~!$'()*+,;=:@%]+)*\/?$/
|
10
|
+
|
11
|
+
def validate_uid(uid, required: false)
|
12
|
+
return nil if uid.nil? && !required
|
13
|
+
raise ArgumentError, "uid must be a string" unless uid.is_a?(String)
|
14
|
+
raise ArgumentError, "uid must be non-empty with no more than 128 chars" unless uid.length.between?(1, 128)
|
15
|
+
uid
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate_email(email, required: false)
|
19
|
+
return nil if email.nil? && !required
|
20
|
+
raise ArgumentError, "email must be a non-empty string" unless email.is_a?(String) && !email.empty?
|
21
|
+
parts = email.split("@")
|
22
|
+
raise ArgumentError, "email is malformed #{email}" unless parts.length == 2 && !parts[0].empty? && !parts[1].empty?
|
23
|
+
email
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_phone_number(phone_number, required: false)
|
27
|
+
return nil if phone_number.nil? && !required
|
28
|
+
raise ArgumentError, "phone_number must be a non-empty string" unless phone_number.is_a?(String)
|
29
|
+
raise ArgumentError, "phone_number must be an E.164 identifier" unless phone_number.match?(/^\+\d{1,14}$/)
|
30
|
+
phone_number
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_password(password, required: false)
|
34
|
+
return nil if password.nil? && !required
|
35
|
+
raise ArgumentError, "password must a string" unless password.is_a?(String)
|
36
|
+
raise ArgumentError, "password must be at least 6 characters long" unless password.length >= 6
|
37
|
+
password
|
38
|
+
end
|
39
|
+
|
40
|
+
def validate_photo_url(url, required: false)
|
41
|
+
return nil if url.nil? && !required
|
42
|
+
raise ArgumentError, "photo_url must be a valid url" unless url.is_a?(String) && !url.empty?
|
43
|
+
raise ArgumentError, "photo_url must be a valid url" unless validate_url(url)
|
44
|
+
url
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate_display_name(name, required: false)
|
48
|
+
return nil if name.nil? && !required
|
49
|
+
raise ArgumentError, "display_name must be a non-empty string" unless name.is_a?(String) && !name.empty?
|
50
|
+
name
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_boolean(val)
|
54
|
+
!!val unless val.nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
module_function
|
58
|
+
|
59
|
+
def validate_url(url)
|
60
|
+
return false unless url.is_a?(String) && !url.empty? && !url.match?(INVALID_CHARS_PATTERN)
|
61
|
+
begin
|
62
|
+
uri = URI.parse(url)
|
63
|
+
return false unless %w[https http].include?(uri.scheme)
|
64
|
+
return false unless uri.hostname&.match?(HOSTNAME_PATTERN)
|
65
|
+
return false unless uri.path.empty? || uri.path == "/" || uri.path.match?(PATHNAME_PATTERN)
|
66
|
+
true
|
67
|
+
rescue
|
68
|
+
false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_emulator_host
|
73
|
+
emulator_host = ENV[AUTH_EMULATOR_HOST_VAR]&.strip
|
74
|
+
return nil unless emulator_host && !emulator_host.empty?
|
75
|
+
if emulator_host.include?("//")
|
76
|
+
msg = "Invalid #{AUTH_EMULATOR_HOST_VAR}: \"#{emulator_host}\". It must follow the format \"host:post\""
|
77
|
+
raise ArgumentError, msg
|
78
|
+
end
|
79
|
+
emulator_host
|
80
|
+
end
|
81
|
+
|
82
|
+
def get_emulator_v1_url
|
83
|
+
return nil unless (emulator_host = get_emulator_host)
|
84
|
+
"http://#{emulator_host}/identitytoolkit.googleapis.com/v1"
|
85
|
+
end
|
86
|
+
|
87
|
+
def is_emulated?
|
88
|
+
!!get_emulator_host
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|