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.
@@ -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