firebase-admin-sdk 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|