sign_in_with_apple_user_migrator 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f60fa992a310fa6ba3ebcddb971abb31897d84293702c6f2e14bb6b26663c654
4
+ data.tar.gz: 498ac47a5d993cf75614c3c1b8278dab75e5fa01cf1d40d6d0a327a00cec1d27
5
+ SHA512:
6
+ metadata.gz: 3731f221c5f566f1b530b2c147d9513e2a3be7220184ad7fe40da162d43c74309097e0b39c0c210cfd0216d5dc11cd973b608e24662749db18bf4c0f75f53364
7
+ data.tar.gz: 447c998bb2eb493b92632973281626a078ecceb0030e6a6ef8260431858693ab80c6ad09cacfc036918960344941f046a40098d76a13b3069bb7c22e71e165e1
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/sign_in_with_apple_user_migrator'
5
+
6
+ SignInWithAppleUserMigrator::CLI.start(ARGV)
@@ -0,0 +1,61 @@
1
+ require_relative 'base'
2
+
3
+ module SignInWithAppleUserMigrator
4
+ class AccessTokenGenerator < Base
5
+ def initialize(client_id:, client_secret:)
6
+ @client_id = client_id
7
+ @client_secret = client_secret
8
+ super()
9
+ end
10
+
11
+ ##
12
+ # Retrieves an access token from Apple's authentication servers
13
+ #
14
+ # @param [String] grant_type The type of grant to request (default: 'client_credentials')
15
+ # @param [String] scope The requested scope for the access token (default: 'user.migration')
16
+ #
17
+ # @return [String] The access token string from Apple's authentication server
18
+ #
19
+ # @example
20
+ # generator = AccessTokenGenerator.new
21
+ # token = generator.get_access_token
22
+ # # => "eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp..."
23
+ #
24
+ # @raise [StandardError] If the token request fails
25
+ #
26
+ def get_access_token(grant_type: 'client_credentials', scope: 'user.migration')
27
+ logger.debug 'Retrieving access token...'
28
+
29
+ uri = URI('https://appleid.apple.com/auth/token')
30
+ params = {
31
+ 'client_id' => @client_id,
32
+ 'client_secret' => @client_secret,
33
+ 'grant_type' => grant_type,
34
+ 'scope' => scope,
35
+ }
36
+
37
+ http = Net::HTTP.new(uri.host, uri.port)
38
+ http.use_ssl = true
39
+
40
+ request = Net::HTTP::Post.new(uri)
41
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
42
+ request.body = URI.encode_www_form(params)
43
+
44
+ response = http.request(request)
45
+
46
+ if response.code != '200'
47
+ logger.error "HTTP Status: #{response.code}"
48
+ logger.error "Response HEADER: #{response.to_hash}"
49
+ raise AuthorizationError.new <<~LOG
50
+ Failed to retrieve access token.
51
+ HTTP Status: #{response.code}
52
+ Response HEADER: #{response.to_hash}
53
+ error: #{response.inspect}
54
+ LOG
55
+ else
56
+ logger.debug response.body
57
+ JSON.parse(response.body)['access_token']
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,36 @@
1
+ require 'jwt'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'logger'
5
+ require 'time'
6
+
7
+ module SignInWithAppleUserMigrator
8
+ class AuthorizationError < StandardError; end
9
+
10
+ class Base
11
+ attr_reader :logger
12
+
13
+ def initialize()
14
+ @logger = setup_logger
15
+ end
16
+
17
+ private
18
+ def setup_logger
19
+ logger = Logger.new(STDOUT)
20
+ log_level = parse_log_level(ENV['LOG_LEVEL'])
21
+ logger.level = log_level
22
+ logger
23
+ end
24
+
25
+ def parse_log_level(level)
26
+ case level.to_s.upcase
27
+ when 'DEBUG' then Logger::DEBUG
28
+ when 'INFO' then Logger::INFO
29
+ when 'WARN' then Logger::WARN
30
+ when 'ERROR' then Logger::ERROR
31
+ when 'FATAL' then Logger::FATAL
32
+ else Logger::INFO
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,111 @@
1
+ require 'csv'
2
+ require 'fileutils'
3
+ require 'thor'
4
+ require_relative '../sign_in_with_apple_user_migrator'
5
+
6
+ module SignInWithAppleUserMigrator
7
+ class CLI < Thor
8
+ desc 'generate_user_transfer_ids', 'Generate user transfer IDs'
9
+ option :client_id, required: true, desc: 'Client ID'
10
+ option :key_id, required: true, desc: 'Key ID'
11
+ option :private_key_path, required: true, desc: 'Private Key Path'
12
+ option :team_id, required: true, desc: 'Team ID'
13
+ option :transfer_user_id_csv_path, required: true, desc: 'Transfer User ID CSV Path'
14
+ option :target_team_id, required: true, desc: 'Target Team ID'
15
+
16
+ def generate_user_transfer_ids
17
+ client_id = options[:client_id]
18
+ key_id = options[:key_id]
19
+ private_key_path = options[:private_key_path]
20
+ team_id = options[:team_id]
21
+ transfer_user_id_csv_path = options[:transfer_user_id_csv_path]
22
+ target_team_id = options[:target_team_id]
23
+
24
+ client_secret = SignInWithAppleUserMigrator::ClientSecretGenerator.new(
25
+ client_id: client_id,
26
+ key_id: key_id,
27
+ private_key_path: private_key_path,
28
+ team_id: team_id,
29
+ ).generate_client_secret
30
+
31
+ access_token = SignInWithAppleUserMigrator::AccessTokenGenerator.new(
32
+ client_id: client_id,
33
+ client_secret: client_secret,
34
+ ).get_access_token
35
+
36
+ transfer_id_generator_client = SignInWithAppleUserMigrator::TransferIdGenerator.new(
37
+ client_id: client_id,
38
+ client_secret: client_secret,
39
+ access_token: access_token,
40
+ target_team_id: target_team_id,
41
+ )
42
+
43
+ FileUtils.mkdir_p('tmp')
44
+ result_csv_name = "generated_transfer_ids_#{Time.now.strftime('%Y%m%d%H%M%S')}.csv"
45
+ result_path = File.join('tmp', result_csv_name)
46
+ File.write("tmp/#{result_csv_name}", "user_id,transfer_id,error\n")
47
+
48
+ CSV.open(result_path, 'w') do |csv|
49
+ csv << %w[user_id transfer_id error]
50
+
51
+ CSV.foreach(transfer_user_id_csv_path, headers: true) do |row|
52
+ user_id = row['user_id']
53
+ next if user_id.nil? || user_id.empty?
54
+
55
+ transfer_id, error = transfer_id_generator_client.generate_transfer_id(user_id)
56
+ csv << [user_id, transfer_id, error]
57
+ end
58
+ end
59
+ end
60
+
61
+ desc 'migrate_user_transfer_ids', 'Migrate user transfer IDs'
62
+ option :client_id, required: true, desc: 'Client ID'
63
+ option :key_id, required: true, desc: 'Key ID'
64
+ option :private_key_path, required: true, desc: 'Private Key Path'
65
+ option :team_id, required: true, desc: 'Team ID'
66
+ option :transfer_id_csv_path, required: true, desc: 'Transfer ID CSV Path'
67
+
68
+ def migrate_user_transfer_ids
69
+ client_id = options[:client_id]
70
+ key_id = options[:key_id]
71
+ private_key_path = options[:private_key_path]
72
+ team_id = options[:team_id]
73
+ transfer_id_csv_path = options[:transfer_id_csv_path]
74
+
75
+ client_secret = SignInWithAppleUserMigrator::ClientSecretGenerator.new(
76
+ client_id: client_id,
77
+ key_id: key_id,
78
+ private_key_path: private_key_path,
79
+ team_id: team_id,
80
+ ).generate_client_secret
81
+
82
+ access_token = SignInWithAppleUserMigrator::AccessTokenGenerator.new(
83
+ client_id: client_id,
84
+ client_secret: client_secret,
85
+ ).get_access_token
86
+
87
+ transfer_id_exchanger_client = SignInWithAppleUserMigrator::TransferIdExchanger.new(
88
+ client_id: client_id,
89
+ client_secret: client_secret,
90
+ access_token: access_token,
91
+ )
92
+
93
+ FileUtils.mkdir_p('tmp')
94
+ result_csv_name = "exchanged_transfer_ids_#{Time.now.strftime('%Y%m%d%H%M%S')}.csv"
95
+ result_path = File.join('tmp', result_csv_name)
96
+ File.write("tmp/#{result_csv_name}", "transfer_id,sub,error\n")
97
+
98
+ CSV.open(result_path, 'w') do |csv|
99
+ csv << %w[transfer_id sub email is_private_email error]
100
+
101
+ CSV.foreach(transfer_id_csv_path, headers: true) do |row|
102
+ transfer_id = row['transfer_id']
103
+ next if transfer_id.nil? || transfer_id.empty?
104
+
105
+ transfer_id, sub, email, is_private_email, error = transfer_id_exchanger_client.migrate(transfer_id)
106
+ csv << [transfer_id, sub, email, is_private_email, error]
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,45 @@
1
+ require_relative 'base'
2
+
3
+ module SignInWithAppleUserMigrator
4
+ class ClientSecretGenerator < Base
5
+ def initialize(client_id:, key_id:, private_key_path:, team_id:)
6
+ @client_id = client_id
7
+ @key_id = key_id
8
+ @private_key = File.read(private_key_path)
9
+ @team_id = team_id
10
+ super()
11
+ end
12
+
13
+ ##
14
+ # Generates a JSON Web Token (JWT) for Sign in with Apple authentication (client_secret).
15
+ #
16
+ # @param [Integer] iat The issued at time (default: current Unix timestamp)
17
+ # @param [Integer] exp The expiration time (default: current time + 1 hour)
18
+ #
19
+ # @return [String] A signed JWT token that can be used as client_secret
20
+ #
21
+ # @example
22
+ # generator = ClientSecretGenerator.new
23
+ # jwt = generator.generate_client_secret
24
+ # # => "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1..."
25
+ #
26
+ # @see https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
27
+ #
28
+ def generate_client_secret(iat: Time.now.to_i, exp: Time.now.to_i + 3600)
29
+ header = {
30
+ 'kid' => @key_id,
31
+ 'alg' => 'ES256',
32
+ }
33
+
34
+ payload = {
35
+ 'iss' => @team_id,
36
+ 'iat' => iat,
37
+ 'exp' => exp,
38
+ 'aud' => 'https://appleid.apple.com',
39
+ 'sub' => @client_id,
40
+ }
41
+
42
+ JWT.encode(payload, OpenSSL::PKey::EC.new(@private_key), 'ES256', header)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,68 @@
1
+ require_relative 'base'
2
+
3
+ module SignInWithAppleUserMigrator
4
+ class TransferIdExchanger < Base
5
+ def initialize(client_id:, client_secret:, access_token:)
6
+ @client_id = client_id
7
+ @client_secret = client_secret
8
+ @access_token = access_token
9
+ super()
10
+ end
11
+
12
+ ##
13
+ # Exchanges a transfer_id for user information in Sign in with Apple user migration process.
14
+ #
15
+ # @param transfer_id [String] The transfer_id obtained from the source team
16
+ #
17
+ # @return [Array<String, String, String, Boolean, String>]
18
+ # Success: [transfer_id, sub, email, is_private_email, nil]
19
+ # Failure: [transfer_id, nil, nil, nil, error_message]
20
+ #
21
+ # @example
22
+ # exchanger = TransferIdExchanger.new(
23
+ # client_id: 'your_client_id',
24
+ # client_secret: 'your_client_secret',
25
+ # access_token: 'your_access_token'
26
+ # )
27
+ # transfer_id, sub, email, is_private_email, error = exchanger.migrate('transfer_id')
28
+ #
29
+ # @raise [JSON::ParserError] When JSON parsing of response fails
30
+ #
31
+ # @see https://developer.apple.com/documentation/sign_in_with_apple/transferring_your_apps_and_users_to_another_team
32
+ #
33
+ def migrate(transfer_id)
34
+ logger.debug "Migrate transfer_id for #{transfer_id}"
35
+
36
+ request_data = {
37
+ 'transfer_sub' => transfer_id,
38
+ 'client_id' => @client_id,
39
+ 'client_secret' => @client_secret,
40
+ }
41
+
42
+ uri = URI('https://appleid.apple.com/auth/usermigrationinfo')
43
+ request = Net::HTTP::Post.new(uri)
44
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
45
+ request['Authorization'] = "Bearer #{@access_token}"
46
+
47
+ request.body = URI.encode_www_form(request_data)
48
+
49
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
50
+ http.request(request)
51
+ end
52
+
53
+ result = JSON.parse(response.body)
54
+
55
+ if result['error']
56
+ error_message = "HTTP Status: #{response.code}, Error: #{result.inspect}"
57
+ logger.error "Failed to exchange transfer_id: #{error_message}"
58
+ return [transfer_id, nil, nil, nil, error_message]
59
+ end
60
+
61
+ sub = result['sub']
62
+ email = result['email']
63
+ is_private_email = result['is_private_email']
64
+ logger.info "Success to migrate sub: #{transfer_id}"
65
+ [transfer_id, sub, email, is_private_email, nil]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ require_relative 'base'
2
+
3
+ module SignInWithAppleUserMigrator
4
+ class TransferIdGenerator < Base
5
+ def initialize(client_id:, client_secret:, access_token:, target_team_id:)
6
+ @client_id = client_id
7
+ @client_secret = client_secret
8
+ @target_team_id = target_team_id
9
+ @access_token = access_token
10
+ super()
11
+ end
12
+
13
+ ##
14
+ # Generates transfer_id required for Sign in with Apple user migration.
15
+ #
16
+ # @param sub [String] The user identifier (sub) from Sign in with Apple
17
+ #
18
+ # @return [Array<String, String>]
19
+ # Success: [transfer_id, nil]
20
+ # Failure: [nil, error_message]
21
+ #
22
+ # @example
23
+ # generator = TransferIdGenerator.new(
24
+ # client_id: 'your_client_id',
25
+ # client_secret: 'your_client_secret',
26
+ # access_token: 'your_access_token',
27
+ # target_team_id: 'target_team_id'
28
+ # )
29
+ # transfer_id, error = generator.generate_transfer_id('user_sub')
30
+ #
31
+ # @raise [JSON::ParserError] When JSON parsing of response fails
32
+ #
33
+ # @see https://developer.apple.com/documentation/sign_in_with_apple/transferring_your_apps_and_users_to_another_team
34
+ #
35
+ def generate_transfer_id(sub)
36
+ logger.debug "Generated transfer_id for #{sub}"
37
+
38
+ request_data = {
39
+ 'sub' => sub,
40
+ 'target' => @target_team_id,
41
+ 'client_id' => @client_id,
42
+ 'client_secret' => @client_secret,
43
+ }
44
+
45
+ uri = URI('https://appleid.apple.com/auth/usermigrationinfo')
46
+ request = Net::HTTP::Post.new(uri)
47
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
48
+ request['Authorization'] = "Bearer #{@access_token}"
49
+
50
+ request.body = URI.encode_www_form(request_data)
51
+
52
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
53
+ http.request(request)
54
+ end
55
+
56
+ result = JSON.parse(response.body)
57
+
58
+ if result['error']
59
+ error_message = "HTTP Status: #{response.code}, Error: #{result.inspect}"
60
+ logger.error "Failed to generate transfer_id: #{error_message}"
61
+ return [nil, error_message]
62
+ end
63
+
64
+ transfer_id = result['transfer_sub']
65
+ logger.info "Success to generate transfer_id: #{transfer_id}"
66
+ [transfer_id, nil]
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,3 @@
1
+ module SignInWithAppleUserMigrator
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'sign_in_with_apple_user_migrator/access_token_generator'
2
+ require_relative 'sign_in_with_apple_user_migrator/cli'
3
+ require_relative 'sign_in_with_apple_user_migrator/client_secret_generator'
4
+ require_relative 'sign_in_with_apple_user_migrator/transfer_id_exchanger'
5
+ require_relative 'sign_in_with_apple_user_migrator/transfer_id_generator'
6
+ require_relative 'sign_in_with_apple_user_migrator/version'
7
+
8
+ module SignInWithAppleUserMigrator
9
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sign_in_with_apple_user_migrator
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - sakurahigashi2
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3'
83
+ description: Tool for migrating Apple Sign In users between teams
84
+ email:
85
+ - joh.murata@gmail.com
86
+ executables:
87
+ - sign_in_with_apple_user_migrator
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - bin/sign_in_with_apple_user_migrator
92
+ - lib/sign_in_with_apple_user_migrator.rb
93
+ - lib/sign_in_with_apple_user_migrator/access_token_generator.rb
94
+ - lib/sign_in_with_apple_user_migrator/base.rb
95
+ - lib/sign_in_with_apple_user_migrator/cli.rb
96
+ - lib/sign_in_with_apple_user_migrator/client_secret_generator.rb
97
+ - lib/sign_in_with_apple_user_migrator/transfer_id_exchanger.rb
98
+ - lib/sign_in_with_apple_user_migrator/transfer_id_generator.rb
99
+ - lib/sign_in_with_apple_user_migrator/version.rb
100
+ homepage: https://github.com/sakurahigashi2/sign_in_with_apple_user_migrator
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ documentation_uri: https://github.com/sakurahigashi2/sign_in_with_apple_user_migrator
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 2.5.0
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.4.10
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: Apple Sign In User Migration Tool
124
+ test_files: []