firebase-admin-sdk 0.1.0

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