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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_support/core_ext/integer/time")
4
+ require("httparty")
5
+
6
+ module YiffSpace
7
+ class LogtoManagementClient
8
+ attr_reader(:auth)
9
+
10
+ def initialize(auth)
11
+ @auth = auth
12
+ end
13
+
14
+ def get_token # rubocop:disable Naming/AccessorMethodName
15
+ if @_cached_token
16
+ return @_cached_token if @_cache_expire_time > Time.current
17
+
18
+ @_cached_token = nil
19
+ @_cache_expire_time = nil
20
+ end
21
+ response = HTTParty.post("#{auth.server_url}/oidc/token", {
22
+ body: {
23
+ grant_type: "client_credentials",
24
+ client_id: YiffSpace.config.logto_api_client_id,
25
+ client_secret: YiffSpace.config.logto_api_client_secret,
26
+ resource: YiffSpace.config.logto_api_resource,
27
+ scope: "all",
28
+ },
29
+ })
30
+ raise("failed to get access token: #{response.to_json}") unless response.key?("access_token")
31
+
32
+ @_cached_token = response["access_token"]
33
+ @_cache_expire_time = response["expires_in"].to_i.seconds.from_now
34
+ response["access_token"]
35
+ end
36
+
37
+ def get_user(id)
38
+ response = HTTParty.get("#{auth.server_url}/api/users?discordId=#{id}", { headers: { "Authorization" => "Bearer #{get_token}" } })
39
+ return nil if response.code == 404 || (response.parsed_response.is_a?(Array) && response.parsed_response.empty?)
40
+ raise("failed to get user: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200 || !response.parsed_response.is_a?(Array)
41
+
42
+ Utils::TraceLogger.warn("LogtoManagementClient", "query discordId=#{id} returned more than one user:\n#{response.parsed_response.inspect}") if response.parsed_response.length > 1
43
+
44
+ Auth::ApiUser.new(response.parsed_response.first)
45
+ end
46
+
47
+ def create_user(id)
48
+ details = auth.fetch_discord_user(id)&.data
49
+ raise("invalid discord user: #{id}") if details.blank?
50
+
51
+ response = HTTParty.post("#{auth.server_url}/api/users", {
52
+ headers: { "Authorization" => "Bearer #{get_token}", "Content-Type" => "application/json" },
53
+ body: { avatar: "https://cdn.discordapp.com/avatars/#{details['id']}/#{details['avatar']}", name: details["username"] }.to_json,
54
+ })
55
+ raise("failed to create user: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
56
+
57
+ response2 = HTTParty.put("#{auth.server_url}/api/users/#{response.parsed_response['id']}/identities/discord", {
58
+ headers: { "Authorization" => "Bearer #{get_token}", "Content-Type" => "application/json" },
59
+ body: {
60
+ userId: details["id"],
61
+ details: {
62
+ id: details["id"],
63
+ name: details["username"],
64
+ avatar: "https://cdn.discordapp.com/avatars/#{details['id']}/#{details['avatar']}",
65
+ rawData: details,
66
+ },
67
+ }.to_json,
68
+ })
69
+ raise("failed to add discord identity: #{response2.code} #{response2.message}\n#{response2.parsed_response.inspect}") if response2.code != 201
70
+
71
+ get_user(id) # force fresh fetch to ensure identity data is included properly
72
+ end
73
+
74
+ def get_or_create_user(id)
75
+ get_user(id) || create_user(id)
76
+ end
77
+
78
+ # Pages through every user in the tenant. Used by maintenance tasks (e.g. the
79
+ # dedupe_users rake task) - there's no discordId to filter by when scanning the
80
+ # whole user base for duplicates/orphans.
81
+ def list_users(page_size: 100)
82
+ users = []
83
+ page = 1
84
+ loop do
85
+ response = HTTParty.get("#{auth.server_url}/api/users", {
86
+ headers: { "Authorization" => "Bearer #{get_token}" },
87
+ query: { page: page, page_size: page_size },
88
+ })
89
+ raise("failed to list users: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200 || !response.parsed_response.is_a?(Array)
90
+
91
+ batch = response.parsed_response
92
+ users.concat(batch.map { |u| Auth::ApiUser.new(u) })
93
+ break if batch.length < page_size
94
+
95
+ page += 1
96
+ end
97
+ users
98
+ end
99
+
100
+ def delete_user(logto_id)
101
+ response = HTTParty.delete("#{auth.server_url}/api/users/#{logto_id}", { headers: { "Authorization" => "Bearer #{get_token}" } })
102
+ return true if [204, 404].include?(response.code)
103
+
104
+ raise("failed to delete user #{logto_id}: #{response.code} #{response.message}\n#{response.parsed_response.inspect}")
105
+ end
106
+
107
+ def get_user_by_id(logto_id)
108
+ response = HTTParty.get("#{auth.server_url}/api/users/#{logto_id}", { headers: { "Authorization" => "Bearer #{get_token}" } })
109
+ return nil if response.code == 404
110
+ raise("failed to get user: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
111
+
112
+ Auth::ApiUser.new(response.parsed_response)
113
+ end
114
+
115
+ def get_user_roles(logto_id)
116
+ response = HTTParty.get("#{auth.server_url}/api/users/#{logto_id}/roles", { headers: { "Authorization" => "Bearer #{get_token}" } })
117
+ raise("failed to get user roles: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
118
+
119
+ response.parsed_response
120
+ end
121
+
122
+ def get_role_scopes(role_id)
123
+ response = HTTParty.get("#{auth.server_url}/api/roles/#{role_id}/scopes", { headers: { "Authorization" => "Bearer #{get_token}" } })
124
+ raise("failed to get role scopes: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
125
+
126
+ response.parsed_response
127
+ end
128
+
129
+ def get_users_with_role(role_id)
130
+ response = HTTParty.get("#{auth.server_url}/api/roles/#{role_id}/users", { headers: { "Authorization" => "Bearer #{get_token}" } })
131
+ raise("failed to get users with role: #{response.code} #{response.message}\n#{response.parsed_response.inspect}") if response.code != 200
132
+
133
+ response.parsed_response
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class AnonymousAuthInfoSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(_)
9
+ super({})
10
+ end
11
+
12
+ def deserialize(_)
13
+ Auth::AuthInfo::Anonymous.instance
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::AuthInfo::Anonymous
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class AnonymousUserInfoSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(_)
9
+ super({})
10
+ end
11
+
12
+ def deserialize(_)
13
+ Auth::UserInfo::Anonymous.instance
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::UserInfo::Anonymous
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class AuthInfoSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(arg)
9
+ super(**arg.serializable_hash)
10
+ end
11
+
12
+ def deserialize(arg)
13
+ Auth::AuthInfo.from_json(arg)
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::AuthInfo
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class DiscordInfoSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(arg)
9
+ super(**arg.serializable_hash)
10
+ end
11
+
12
+ def deserialize(arg)
13
+ Auth::DiscordInfo.from_json(arg)
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::DiscordInfo
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class PermissionsSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(arg)
9
+ super("value" => arg.value)
10
+ end
11
+
12
+ def deserialize(arg)
13
+ Auth::Permissions.new(arg["value"])
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::Permissions
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("active_job")
4
+
5
+ module YiffSpace
6
+ module Serializers
7
+ class UserInfoSerializer < ActiveJob::Serializers::ObjectSerializer
8
+ def serialize(arg)
9
+ super(**arg.serializable_hash)
10
+ end
11
+
12
+ def deserialize(arg)
13
+ Auth::UserInfo.from_json(arg)
14
+ end
15
+
16
+ private
17
+
18
+ def klass
19
+ Auth::UserInfo
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YiffSpace
4
+ module Utils
5
+ # get_or_create_user is check-then-act (query Logto, then create) with no locking
6
+ # of its own - two concurrent calls for a discord id that doesn't exist yet will
7
+ # both create a user, and the loser's discord-identity-attach step fails once the
8
+ # winner's identity claims it first. That leaves an identity-less "orphan" user
9
+ # behind in Logto. This scans the whole user base to find those orphans (and any
10
+ # other duplicate/ambiguous state) so they can be cleaned up.
11
+ module UserDeduper
12
+ # create_user sets a new user's avatar to the discord CDN URL for the discord id
13
+ # it was created for, before the identity-attach step runs - so even an orphan
14
+ # that never got its identity attached still reveals which discord id it was
15
+ # meant for.
16
+ AVATAR_ID_PATTERN = %r{cdn\.discordapp\.com/avatars/(\d+)/}
17
+
18
+ module_function
19
+
20
+ def discord_id_for(user)
21
+ user.data.identities&.discord&.userId || avatar_discord_id_for(user)
22
+ end
23
+
24
+ def avatar_discord_id_for(user)
25
+ match = user.data.avatar.to_s.match(AVATAR_ID_PATTERN)
26
+ match && match[1]
27
+ end
28
+
29
+ def linked?(user)
30
+ user.data.identities&.discord&.userId.present?
31
+ end
32
+
33
+ # Returns a hash with:
34
+ # - :conflicts - discord id => users, for ids where more than one user has the
35
+ # identity actually attached. Ambiguous; always needs manual review, never
36
+ # auto-deleted.
37
+ # - :deletable_orphans - [orphan, keeper] pairs, where orphan has no identity
38
+ # attached but its avatar points at a discord id claimed by exactly one other
39
+ # (linked) user. This is the confirmed race-loser case - safe to delete.
40
+ # - :unresolved_orphans - users with no identity attached and no corresponding
41
+ # linked user (still mid-race, or the winner can't be identified). Reported
42
+ # only, never auto-deleted.
43
+ def scan(management)
44
+ users = management.list_users
45
+ linked, unlinked = users.partition { |u| linked?(u) }
46
+
47
+ by_discord_id = linked.group_by { |u| u.data.identities.discord.userId }
48
+ conflicts = by_discord_id.select { |_id, group| group.length > 1 }
49
+
50
+ deletable_orphans = []
51
+ unresolved_orphans = []
52
+ unlinked.each do |user|
53
+ discord_id = avatar_discord_id_for(user)
54
+ keeper = discord_id && by_discord_id[discord_id]
55
+ if keeper&.length == 1
56
+ deletable_orphans << [user, keeper.first]
57
+ else
58
+ unresolved_orphans << user
59
+ end
60
+ end
61
+
62
+ { conflicts: conflicts, deletable_orphans: deletable_orphans, unresolved_orphans: unresolved_orphans }
63
+ end
64
+ end
65
+ end
66
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yiffspace-auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Donovan_DMC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-07-14 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: httparty
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.24'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logto
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.2.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: yiffspace
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.1.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.1.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: zeitwerk
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '2.6'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '2.6'
82
+ description: Logto-based auth engine for https://yiff.space and related projects
83
+ email:
84
+ - hewwo@yiff.rocks
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - CHANGELOG.md
90
+ - LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - app/controllers/yiff_space/auth/application_controller.rb
94
+ - app/controllers/yiff_space/auth/root_controller.rb
95
+ - app/controllers/yiff_space/auth/webhook_controller.rb
96
+ - app/helpers/yiff_space/auth_helper.rb
97
+ - app/views/yiff_space/auth/root/permissions.html.erb
98
+ - config/routes.rb
99
+ - lib/tasks/yiffspace_auth_tasks.rake
100
+ - lib/yiffspace/auth.rb
101
+ - lib/yiffspace/auth/api_user.rb
102
+ - lib/yiffspace/auth/auth_info.rb
103
+ - lib/yiffspace/auth/auth_info/anonymous.rb
104
+ - lib/yiffspace/auth/client.rb
105
+ - lib/yiffspace/auth/discord_info.rb
106
+ - lib/yiffspace/auth/engine.rb
107
+ - lib/yiffspace/auth/helper.rb
108
+ - lib/yiffspace/auth/permissions.rb
109
+ - lib/yiffspace/auth/set_client_name.rb
110
+ - lib/yiffspace/auth/user_info.rb
111
+ - lib/yiffspace/auth/user_info/anonymous.rb
112
+ - lib/yiffspace/auth/version.rb
113
+ - lib/yiffspace/core_ext/action_dispatch/set_auth_client.rb
114
+ - lib/yiffspace/core_ext/action_dispatch/set_auth_client/scoped.rb
115
+ - lib/yiffspace/core_ext/logto/named_session_storage.rb
116
+ - lib/yiffspace/extensions/action_dispatch/set_auth_client.rb
117
+ - lib/yiffspace/extensions/action_dispatch/set_auth_client/scoped.rb
118
+ - lib/yiffspace/extensions/logto/named_session_storage.rb
119
+ - lib/yiffspace/logto_management_client.rb
120
+ - lib/yiffspace/serializers/anonymous_auth_info_serializer.rb
121
+ - lib/yiffspace/serializers/anonymous_user_info_serializer.rb
122
+ - lib/yiffspace/serializers/auth_info_serializer.rb
123
+ - lib/yiffspace/serializers/discord_info_serializer.rb
124
+ - lib/yiffspace/serializers/permissions_serializer.rb
125
+ - lib/yiffspace/serializers/user_info_serializer.rb
126
+ - lib/yiffspace/utils/user_deduper.rb
127
+ homepage: https://yiff.space
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ allowed_push_host: https://rubygems.org
132
+ homepage_uri: https://yiff.space
133
+ source_code_uri: https://github.com/YiffSpace/Auth.rb
134
+ changelog_uri: https://github.com/YiffSpace/Auth.rb/blob/master/CHANGELOG.md
135
+ rubygems_mfa_required: 'true'
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: 3.4.1
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ requirements: []
150
+ rubygems_version: 3.6.2
151
+ specification_version: 4
152
+ summary: Logto-based auth engine for https://yiff.space and related projects
153
+ test_files: []