yiffspace-auth 0.0.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +20 -0
  4. data/README.md +41 -0
  5. data/Rakefile +10 -0
  6. data/app/controllers/yiff_space/auth/application_controller.rb +11 -0
  7. data/app/controllers/yiff_space/auth/root_controller.rb +49 -0
  8. data/app/controllers/yiff_space/auth/webhook_controller.rb +73 -0
  9. data/app/helpers/yiff_space/auth_helper.rb +6 -0
  10. data/app/views/yiff_space/auth/root/permissions.html.erb +14 -0
  11. data/config/routes.rb +12 -0
  12. data/lib/tasks/yiffspace_auth_tasks.rake +41 -0
  13. data/lib/yiffspace/auth/api_user.rb +43 -0
  14. data/lib/yiffspace/auth/auth_info/anonymous.rb +60 -0
  15. data/lib/yiffspace/auth/auth_info.rb +78 -0
  16. data/lib/yiffspace/auth/client.rb +56 -0
  17. data/lib/yiffspace/auth/discord_info.rb +65 -0
  18. data/lib/yiffspace/auth/engine.rb +49 -0
  19. data/lib/yiffspace/auth/helper.rb +174 -0
  20. data/lib/yiffspace/auth/permissions.rb +87 -0
  21. data/lib/yiffspace/auth/set_client_name.rb +15 -0
  22. data/lib/yiffspace/auth/user_info/anonymous.rb +72 -0
  23. data/lib/yiffspace/auth/user_info.rb +87 -0
  24. data/lib/yiffspace/auth/version.rb +7 -0
  25. data/lib/yiffspace/auth.rb +81 -0
  26. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client/scoped.rb +13 -0
  27. data/lib/yiffspace/core_ext/action_dispatch/set_auth_client.rb +13 -0
  28. data/lib/yiffspace/core_ext/logto/named_session_storage.rb +7 -0
  29. data/lib/yiffspace/extensions/action_dispatch/set_auth_client/scoped.rb +15 -0
  30. data/lib/yiffspace/extensions/action_dispatch/set_auth_client.rb +13 -0
  31. data/lib/yiffspace/extensions/logto/named_session_storage.rb +22 -0
  32. data/lib/yiffspace/logto_management_client.rb +136 -0
  33. data/lib/yiffspace/serializers/anonymous_auth_info_serializer.rb +23 -0
  34. data/lib/yiffspace/serializers/anonymous_user_info_serializer.rb +23 -0
  35. data/lib/yiffspace/serializers/auth_info_serializer.rb +23 -0
  36. data/lib/yiffspace/serializers/discord_info_serializer.rb +23 -0
  37. data/lib/yiffspace/serializers/permissions_serializer.rb +23 -0
  38. data/lib/yiffspace/serializers/user_info_serializer.rb +23 -0
  39. data/lib/yiffspace/utils/user_deduper.rb +66 -0
  40. metadata +153 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_support/concern")
4
+
5
+ module YiffSpace
6
+ module Auth
7
+ module Helper
8
+ extend(ActiveSupport::Concern)
9
+
10
+ module ClassMethods
11
+ def set_client_name(name) # rubocop:disable Naming/AccessorMethodName
12
+ before_action do |controller|
13
+ controller.request.env["yiffspace.auth.client_name"] = name
14
+ if controller.respond_to?(:yiffspace_client_name=)
15
+ controller.yiffspace_client_name = name
16
+ elsif controller.respond_to?(:client_name=)
17
+ controller.client_name = name
18
+ elsif controller.respond_to?(:helpers)
19
+ if controller.helpers.respond_to?(:yiffspace_client_name=)
20
+ controller.helpers.yiffspace_client_name = name
21
+ elsif controller.helpers.respond_to?(:client_name=)
22
+ controller.helpers.client_name = name
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def auth_raw
30
+ session[auth_client_config.auth_session_key]
31
+ end
32
+
33
+ def auth
34
+ return AuthInfo::Anonymous.instance if auth_raw.blank?
35
+
36
+ AuthInfo.from_session(auth_raw)
37
+ end
38
+
39
+ def auth?
40
+ auth_raw.present? && !auth.anonymous?
41
+ end
42
+
43
+ def auth=(value)
44
+ value = nil if value.is_a?(AuthInfo::Anonymous)
45
+ session[auth_client_config.auth_session_key] = value&.to_session
46
+ end
47
+
48
+ def reset_auth!
49
+ session.delete(auth_client_config.auth_session_key)
50
+ end
51
+
52
+ def user_raw
53
+ session[auth_client_config.user_session_key]
54
+ end
55
+
56
+ def user
57
+ return UserInfo::Anonymous.instance if user_raw.blank?
58
+
59
+ UserInfo.from_session(user_raw)
60
+ end
61
+
62
+ def user?
63
+ user_raw.present? && !user.anonymous?
64
+ end
65
+
66
+ def user=(value)
67
+ value = nil if value.is_a?(UserInfo::Anonymous)
68
+ session[auth_client_config.user_session_key] = value&.to_session
69
+ end
70
+
71
+ def reset_user!
72
+ session.delete(auth_client_config.user_session_key)
73
+ end
74
+
75
+ def full_reset!
76
+ reset_auth!
77
+ reset_user!
78
+ end
79
+
80
+ def require_auth(path)
81
+ redirect_to(path) unless logged_in?
82
+ end
83
+
84
+ def logged_in?
85
+ auth? && user?
86
+ end
87
+
88
+ def has_permission?(name)
89
+ return false unless logged_in?
90
+
91
+ auth.permissions.has?(name)
92
+ end
93
+
94
+ DIRTY_FLAG_KEY = "yiffspace:auth:dirty:%s"
95
+
96
+ # Checks the dirty flag written by the Logto webhook handler. If set, re-fetches
97
+ # the user's current roles and permissions from the Logto Management API and
98
+ # rewrites the session — without waiting for the access token to expire.
99
+ # Call this as a before_action in any controller that needs instant revocation.
100
+ def sync_auth_if_dirty!
101
+ return unless auth?
102
+
103
+ flag_key = format(DIRTY_FLAG_KEY, auth.id)
104
+ return unless Rails.cache.exist?(flag_key)
105
+
106
+ Rails.cache.delete(flag_key)
107
+
108
+ management = auth_client_config.logto_management
109
+ api_user = management.get_user_by_id(auth.id)
110
+
111
+ if api_user.nil? || api_user.data["isSuspended"]
112
+ full_reset!
113
+ return
114
+ end
115
+
116
+ roles = management.get_user_roles(auth.id)
117
+ permissions = roles.flat_map { |role| management.get_role_scopes(role["id"]) }
118
+ .pluck("name")
119
+ .uniq
120
+
121
+ self.auth = AuthInfo.new(
122
+ id: auth.id,
123
+ token: auth.token,
124
+ roles: roles.pluck("name"),
125
+ permissions: permissions,
126
+ client_id: auth.client_id,
127
+ )
128
+ end
129
+
130
+ def url_helpers
131
+ YiffSpace::Auth::Engine.for(client_name).routes.url_helpers
132
+ end
133
+
134
+ # Returns the Auth::Client for the current request. In auth engine controllers this is
135
+ # resolved from the routing default set by Engine.for; in host app controllers it falls
136
+ # back to the default registered client. Override in your controller to choose a specific
137
+ # client when multiple are registered.
138
+ def auth_client_config
139
+ client_name = self.client_name
140
+ client_name.present? ? YiffSpace::Auth[client_name.to_sym] : YiffSpace::Auth.default
141
+ end
142
+
143
+ def client_name
144
+ respond_to?(:request, true) && request.env[CLIENT_NAME_ENV]
145
+ end
146
+
147
+ def client_name=(value)
148
+ request.env[CLIENT_NAME_ENV] = value.to_sym
149
+ end
150
+
151
+ module Scoped
152
+ extend(ActiveSupport::Concern)
153
+ include(Helper)
154
+
155
+ included do
156
+ private(*Helper.instance_methods(false))
157
+ private_class_method(*Helper::ClassMethods.instance_methods(false))
158
+ end
159
+
160
+ Helper.instance_methods(false).each do |name|
161
+ define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
162
+ end
163
+
164
+ module ClassMethods
165
+ include(Helper::ClassMethods)
166
+
167
+ Helper::ClassMethods.instance_methods(false).each do |name|
168
+ define_method("yiffspace_#{name}") { |*args, **kwargs, &block| send(name, *args, **kwargs, &block) }
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class Permissions
6
+ attr_accessor(:values)
7
+
8
+ delegate(:each, to: :values)
9
+
10
+ include(Enumerable)
11
+
12
+ def initialize(perms, separator: ":")
13
+ @values = perms
14
+ @tree = {}
15
+ @separator = separator
16
+
17
+ perms.each do |perm|
18
+ current = @tree
19
+ parts = perm.split(separator)
20
+
21
+ parts.each do |part|
22
+ current[part] ||= {}
23
+ current = current[part]
24
+ end
25
+
26
+ # mark leaf
27
+ current[:__leaf__] = true
28
+ end
29
+ end
30
+
31
+ def has?(perm)
32
+ current = @tree
33
+ parts = perm.split(@separator)
34
+
35
+ parts.each do |part|
36
+ return false unless current[part]
37
+
38
+ current = current[part]
39
+ end
40
+
41
+ current[:__leaf__] == true
42
+ end
43
+
44
+ alias include? has?
45
+
46
+ def method_missing(name, *_args)
47
+ str = name.to_s
48
+
49
+ if str.end_with?("?")
50
+ key = str[0..-2]
51
+ return @tree[key]&.dig(:__leaf__) == true
52
+ end
53
+
54
+ if @tree.key?(str)
55
+ self.class.new_from_subtree(@tree[str], @separator)
56
+ else
57
+ # return empty node instead of raising
58
+ self.class.new([], separator: @separator)
59
+ end
60
+ end
61
+
62
+ def respond_to_missing?(_name, _include_private = false)
63
+ true
64
+ end
65
+
66
+ def self.new_from_subtree(tree, separator)
67
+ obj = allocate
68
+ obj.instance_variable_set(:@tree, tree)
69
+ obj.instance_variable_set(:@values, leaves_from_tree(tree))
70
+ obj.instance_variable_set(:@separator, separator)
71
+ obj
72
+ end
73
+
74
+ def self.leaves_from_tree(node, prefix = "")
75
+ result = []
76
+ node.each do |key, subtree|
77
+ next if key == :__leaf__
78
+
79
+ path = prefix.empty? ? key : "#{prefix}.#{key}"
80
+ result << path if subtree[:__leaf__]
81
+ result.concat(leaves_from_tree(subtree, path))
82
+ end
83
+ result
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class SetClientName < Utils::SetEnvConstraint
6
+ def initialize(value)
7
+ super(CLIENT_NAME_ENV, value.to_sym)
8
+ end
9
+
10
+ def self.default
11
+ new(DEFAULT_CLIENT_NAME)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("singleton")
4
+
5
+ module YiffSpace
6
+ module Auth
7
+ class UserInfo
8
+ class Anonymous
9
+ include(::Singleton)
10
+
11
+ %i[id user discord avatar].each do |attr|
12
+ define_method(attr) { |*, **| raise(NotImplementedError, "#{attr} is not present on anonymous user") }
13
+ end
14
+
15
+ def username
16
+ "anonymous"
17
+ end
18
+
19
+ def display_name
20
+ "Anonymous"
21
+ end
22
+
23
+ def avatar
24
+ nil
25
+ end
26
+
27
+ def avatar_url
28
+ nil
29
+ end
30
+
31
+ def banner
32
+ nil
33
+ end
34
+
35
+ def banner_url
36
+ nil
37
+ end
38
+
39
+ def anonymous?
40
+ true
41
+ end
42
+
43
+ # this feels wrong, but it hopefully shouldn't break anything
44
+ def present?
45
+ false
46
+ end
47
+
48
+ def blank?
49
+ true
50
+ end
51
+
52
+ def serializable_hash(*)
53
+ nil
54
+ end
55
+
56
+ def to_session
57
+ serializable_hash
58
+ end
59
+
60
+ def self.from_json(*)
61
+ Anonymous.instance
62
+ end
63
+
64
+ def self.from_session(data)
65
+ return nil if data.blank?
66
+
67
+ from_json(data)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ class UserInfo
6
+ attr_reader(:id, :user, :discord)
7
+
8
+ # @param id String
9
+ # @param user LogtoCore::UserInfoResponse
10
+ # @param discord Hash
11
+ def initialize(id:, user:, discord:)
12
+ raise(ArgumentError, "no id provided") if id.blank?
13
+ raise(ArgumentError, "no user provided") if user.blank?
14
+
15
+ @id = id
16
+ @user = user
17
+ @discord = DiscordInfo.from_json(discord)
18
+ end
19
+
20
+ def anonymous?
21
+ false
22
+ end
23
+
24
+ # this feels wrong, but it hopefully shouldn't break anything
25
+ def present?
26
+ true
27
+ end
28
+
29
+ def blank?
30
+ false
31
+ end
32
+
33
+ def avatar(type = nil)
34
+ type.present? ? Images::Avatar.get_for(id, type) : Images::Avatar.default_for(id)
35
+ end
36
+
37
+ def avatar_url(type = nil)
38
+ avatar(type).url
39
+ end
40
+
41
+ def banner(type = nil)
42
+ type.present? ? Images::Banner.get_for(id, type) : Images::Banner.default_for(id)
43
+ end
44
+
45
+ def banner_url(type = nil)
46
+ banner(type).url
47
+ end
48
+
49
+ delegate(:username, to: :discord)
50
+
51
+ def display_name
52
+ discord.global_name
53
+ end
54
+
55
+ def serializable_hash(_options = {})
56
+ {
57
+ "id" => id,
58
+ "discord" => discord.serializable_hash,
59
+ "user" => user.as_json,
60
+ }
61
+ end
62
+
63
+ def to_session
64
+ serializable_hash.without("discord").merge(discord: discord.to_session)
65
+ end
66
+
67
+ def self.from_json(data)
68
+ raise(ArgumentError, "invalid data") if data.blank?
69
+
70
+ data = JSON.parse(data) if data.is_a?(String)
71
+ data = ::YiffSpace::Utils::OpenHash.from(data)
72
+
73
+ new(
74
+ id: data.id,
75
+ discord: data.discord,
76
+ user: data.user,
77
+ )
78
+ end
79
+
80
+ def self.from_session(data)
81
+ return nil if data.blank?
82
+
83
+ from_json(data)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Auth
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("yiffspace")
4
+ require("zeitwerk")
5
+
6
+ loader = Zeitwerk::Loader.for_gem_extension(YiffSpace)
7
+ loader.ignore("#{__dir__}/core_ext")
8
+ loader.ignore("#{__dir__}/auth/engine.rb")
9
+ loader.setup
10
+
11
+ # Require the auth engine eagerly so it registers with Rails before the host app's
12
+ # active_support.initialize_per_engine_zeitwerk_loaders initializer runs. Without this,
13
+ # the engine is only loaded during route drawing (after Zeitwerk setup) and its
14
+ # app/controllers path is never added to the app's autoload roots.
15
+ require_relative("auth/engine") if defined?(Rails)
16
+
17
+ module YiffSpace
18
+ module Auth
19
+ CLIENT_NAME_ENV = "yiffspace.auth.client_name"
20
+ DEFAULT_CLIENT_NAME = :default
21
+
22
+ @clients = {}
23
+ @enable_debug_action = false
24
+
25
+ module_function
26
+
27
+ def register(name, &block)
28
+ client = Client.new(name)
29
+ block&.call(client)
30
+ @clients[name.to_sym] = client
31
+ client
32
+ end
33
+
34
+ def [](name)
35
+ @clients[name.to_sym] || raise(KeyError, "unknown auth client: #{name.inspect}")
36
+ end
37
+
38
+ def default
39
+ @clients[DEFAULT_CLIENT_NAME] || raise("no default client configured")
40
+ end
41
+
42
+ def get_by_id(id)
43
+ @clients.values.find { |c| c.client_id == id } || raise(ArgumentError, "unable to find client with id: #{id}")
44
+ end
45
+
46
+ def enable_debug_action?
47
+ @enable_debug_action
48
+ end
49
+
50
+ def enable_debug_action!
51
+ @enable_debug_action = true
52
+ end
53
+
54
+ def disable_debug_action!
55
+ @enable_debug_action = false
56
+ end
57
+ end
58
+
59
+ class Configuration
60
+ # Logto Management API credentials (shared across all auth clients).
61
+ attr_accessor(:logto_api_client_id, :logto_api_client_secret, :logto_api_resource)
62
+
63
+ # Discord bot token used to look up Discord users (shared across all auth clients).
64
+ attr_accessor(:discord_bot_token)
65
+
66
+ def auth(&block)
67
+ client = YiffSpace::Auth.register(Auth::DEFAULT_CLIENT_NAME) unless YiffSpace::Auth.instance_variable_get(:@clients).key?(Auth::DEFAULT_CLIENT_NAME)
68
+ client ||= YiffSpace::Auth[Auth::DEFAULT_CLIENT_NAME]
69
+ block&.call(client)
70
+ client
71
+ end
72
+
73
+ def add_auth(name, &)
74
+ YiffSpace::Auth.register(name, &)
75
+ end
76
+
77
+ def add_default_auth(&)
78
+ add_auth(Auth::DEFAULT_CLIENT_NAME, &)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("../../../extensions/action_dispatch/set_auth_client/scoped")
4
+
5
+ module ActionDispatch
6
+ module Routing
7
+ class Mapper
8
+ module Scoping
9
+ include(YiffSpace::Extensions::ActionDispatch::SetAuthClient::Scoped)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("../../extensions/action_dispatch/set_auth_client")
4
+
5
+ module ActionDispatch
6
+ module Routing
7
+ class Mapper
8
+ module Scoping
9
+ include(YiffSpace::Extensions::ActionDispatch::SetAuthClient)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative("../../extensions/logto/named_session_storage")
4
+
5
+ class LogtoClient
6
+ NamedSessionStorage = YiffSpace::Extensions::Logto::NamedSessionStorage
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Extensions
5
+ module ActionDispatch
6
+ module SetAuthClient
7
+ module Scoped
8
+ def yiffspace_set_auth_client(name, &)
9
+ constraints(Auth::SetClientName.new(name), &)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Extensions
5
+ module ActionDispatch
6
+ module SetAuthClient
7
+ def set_auth_client(name, &)
8
+ constraints(Auth::SetClientName.new(name), &)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("logto/client")
4
+
5
+ module YiffSpace
6
+ module Extensions
7
+ module Logto
8
+ class NamedSessionStorage < LogtoClient::SessionStorage
9
+ def initialize(name, session, app_id: nil)
10
+ super(session, app_id: app_id)
11
+ @name = name
12
+ end
13
+
14
+ protected
15
+
16
+ def get_session_key(key)
17
+ "#{@name}_#{@app_id || 'default'}_#{key}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end