mysigner 0.1.7 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81a959dbc7121c7d7a05af1428691e2e569924c5aa5312a586b11ea0d828997a
4
- data.tar.gz: d888bb57fc3f8f61abe40dca59be684b9b5eaac913229b783750888de1ef1eb9
3
+ metadata.gz: 0f998d494e2fc03d10bf8efc81bdca8675e5ff6037860a8acf750e66b5f2c2bb
4
+ data.tar.gz: 8c5d7bf17a9cc3556748c6b49106a57ece2f4044e61bfc5c85287e9e7d243d5e
5
5
  SHA512:
6
- metadata.gz: d8765bed9b010e211949014c301bab4bbd05f28da40874ec7d018b2be4c457a9eedd5bea7b00d750e346ee04ef3290cb5416826ebedd3f5615f2dd4fd919fc25
7
- data.tar.gz: e1e0ea6c6a1809dc3287a4972a685d07ce91bb64cacd8f2605d2300926e68b1646e03e9ae74e97ac7b37d7b91767164cf0520e05cee0039a3f010da112513b26
6
+ metadata.gz: 3c3929aaca39e935b3d2cb2030320e1955aa24f443d5f17d1bb9f2d996ef99fb10af71dd910fffb5f1c49293a23f90d4071d3055c47e82fb529f27655f29c926
7
+ data.tar.gz: 3c7a4976b73851217a9f1a892c0b6d7a2e43e15ea0678d61bc7edf2d790d634822bd843c2482760b673c692edaa39d12eab41922e5c59d011854c9835493c845
data/.gitignore CHANGED
@@ -15,3 +15,28 @@
15
15
  # macOS
16
16
  .DS_Store
17
17
  **/.DS_Store
18
+
19
+ # Dotenv / local config
20
+ .env
21
+ .env.*
22
+ *.local
23
+
24
+ # Signing material — must never be committed
25
+ *.p8
26
+ *.p12
27
+ *.pem
28
+ *.key
29
+ *.jks
30
+ *.keystore
31
+ *.cer
32
+ *.mobileprovision
33
+ *.certSigningRequest
34
+ google-service-account*.json
35
+ service-account*.json
36
+
37
+ # Editor / IDE / Ruby LSP
38
+ .idea/
39
+ .vscode/
40
+ /.ruby-lsp/
41
+ *.swp
42
+ *~
data/.rubocop_todo.yml CHANGED
@@ -66,6 +66,7 @@ Metrics/BlockNesting:
66
66
  Metrics/ClassLength:
67
67
  Exclude:
68
68
  - 'lib/mysigner/signing/wizard.rb'
69
+ - 'lib/mysigner/credential_resolver.rb'
69
70
 
70
71
  # Offense count: 26
71
72
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
@@ -91,6 +92,7 @@ Metrics/MethodLength:
91
92
  - 'lib/mysigner/cli/diagnostic_commands.rb'
92
93
  - 'lib/mysigner/cli/resource_commands.rb'
93
94
  - 'lib/mysigner/cli/validate_commands.rb'
95
+ - 'lib/mysigner/credential_resolver.rb'
94
96
  - 'lib/mysigner/signing/wizard.rb'
95
97
  - 'lib/mysigner/upload/app_store_automation.rb'
96
98
 
@@ -102,14 +104,16 @@ Metrics/ModuleLength:
102
104
  - 'lib/mysigner/cli/build_commands.rb'
103
105
  - 'lib/mysigner/cli/diagnostic_commands.rb'
104
106
  - 'lib/mysigner/cli/resource_commands.rb'
107
+ - 'lib/mysigner/credential_resolver.rb'
105
108
 
106
- # Offense count: 5
109
+ # Offense count: 6
107
110
  # Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters.
108
111
  Metrics/ParameterLists:
109
112
  Exclude:
110
113
  - 'lib/mysigner/build/executor.rb'
111
114
  - 'lib/mysigner/signing/keystore_manager.rb'
112
115
  - 'lib/mysigner/upload/asc_rest_uploader.rb'
116
+ - 'lib/mysigner/upload/asc_submitter.rb'
113
117
  - 'lib/mysigner/upload/play_store_uploader.rb'
114
118
 
115
119
  # Offense count: 27
@@ -122,5 +126,6 @@ Metrics/PerceivedComplexity:
122
126
  - 'lib/mysigner/cli/diagnostic_commands.rb'
123
127
  - 'lib/mysigner/cli/resource_commands.rb'
124
128
  - 'lib/mysigner/cli/validate_commands.rb'
129
+ - 'lib/mysigner/credential_resolver.rb'
125
130
  - 'lib/mysigner/signing/wizard.rb'
126
131
  - 'lib/mysigner/upload/app_store_automation.rb'
data/CHANGELOG.md CHANGED
@@ -94,3 +94,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
94
94
  - Progress spinners (TTY::Spinner)
95
95
  - CI/CD templates for GitHub Actions and GitLab CI
96
96
  - Phased release support for App Store
97
+
98
+ ---
99
+
100
+ ## [0.2.0] - 2026-05-26
101
+
102
+ ### Added — `--local-only` mode
103
+
104
+ Brand-new opt-in mode that lets you ship to TestFlight / Play Store
105
+ without sending any signing credentials to the MySigner server.
106
+ Activate via the `--local-only` flag on any command or by setting
107
+ `MYSIGNER_LOCAL_ONLY=1`. Proven end-to-end against a real iOS app
108
+ (real TestFlight upload).
109
+
110
+ - **Credential auto-discovery cascade** (`Mysigner::CredentialResolver`):
111
+ walks per-command flags → env vars (`APP_STORE_CONNECT_API_KEY_*`,
112
+ `GOOGLE_APPLICATION_CREDENTIALS`, `MYSIGNER_KEYSTORE_*` /
113
+ `ANDROID_KEYSTORE_*`) → macOS Keychain (`Mysigner::LocalCredentials`)
114
+ → standard tool locations (`~/.appstoreconnect/private_keys/AuthKey_*.p8`,
115
+ `eas.json`, `android/key.properties`, `android/app/build.gradle[.kts]`
116
+ inline `signingConfigs`, `~/.gradle/gradle.properties`) → interactive
117
+ prompt (TTY-gated; non-TTY fails loud with the exact override knob to
118
+ set).
119
+ - **iOS local-only ship** (`mysigner --local-only ship appstore`):
120
+ bypasses MySigner auth bootstrap entirely (no login required). Mints
121
+ ASC JWT locally and shells out to `xcrun altool --upload-app` (Apple's
122
+ canonical CLI). Submit-for-review automated via the modern 3-step
123
+ `/v1/reviewSubmissions` choreography.
124
+ - **Android local-only ship** (`mysigner --local-only ship play`): mints
125
+ Google OAuth2 access token locally from the discovered SA-JSON.
126
+ Pre-checks Play's highest existing `versionCode` and exits with a
127
+ "bump versionCode to N+1" hint before wasting an upload Google would
128
+ reject. Bypasses every MySigner server endpoint that previously ran
129
+ on the Android path (keystore download, build records, etc.).
130
+ - **`mysigner onboard --local-only`**: walks the user through local
131
+ credential setup interactively; skips the per-platform prompt when
132
+ credentials are already discoverable via the cascade.
133
+
134
+ ### Added — Security & hygiene
135
+
136
+ - **`mysigner logout --purge`**: optionally hard-deletes stored
137
+ credentials on the MySigner server AND wipes local Keychain entries.
138
+ Default behavior prompts (TTY-only; non-TTY defaults to No). New
139
+ `--no-purge` flag opts out without prompting.
140
+ - Two new global flags: `--local-only` and `--auto-submit` /
141
+ `--no-auto-submit`.
142
+ - New per-command flags on `ship`: `--asc-key-path`, `--asc-key-id`,
143
+ `--asc-issuer-id`, `--apple-id`, `--play-credentials`,
144
+ `--keystore-path`, `--keystore-password`, `--key-alias`,
145
+ `--key-password`.
146
+
147
+ ### Changed
148
+
149
+ - "Not logged in" error now also suggests `--local-only` as an
150
+ alternative for users who don't want a MySigner account.
151
+ - `Signing::Validator`'s no-team error message no longer suggests "Add
152
+ team to My Signer" when in `--local-only` mode.
153
+ - Multiple Apple `appStoreState` values handled with actionable typed
154
+ errors during submit-for-review (`VersionInFlightError`,
155
+ `VersionAlreadyReleasedError`, `SubmissionRejectedError`,
156
+ `BuildProcessingTimeoutError`, `AppleApiError`).
157
+ - Submit-for-review poll loop is resilient to transient errors and
158
+ respects HTTP 429 `Retry-After`.
159
+ - `Config#load` uses `YAML.safe_load_file` instead of `YAML.load_file`
160
+ — rejects `!ruby/object:` directives loud.
161
+
162
+ ### Removed (breaking)
163
+
164
+ - **`MYSIGNER_USE_LEGACY_ASC` env var + the legacy altool path it
165
+ gated**. The modern envelope-encryption path (vault mode) and the new
166
+ `--local-only` mode supersede it. Users relying on this env var will
167
+ need to migrate.
168
+ - `Mysigner::Signing::KeystoreManager#list` / `#active_keystore` no
169
+ longer accept the deprecated `include_secrets:` keyword (fetch
170
+ passwords via `#fetch_secrets` instead — already the modern path).
171
+ - Test certs / mobileprovision files removed from the gem bundle.
172
+
173
+ ### Fixed
174
+
175
+ - Thor parser bug where `--local-only` (or any class_option) placed
176
+ before the subcommand was eaten by Thor's command-name lookup,
177
+ silently routing to `help`. The `exe/mysigner` entry point now hoists
178
+ leading class_options past the subcommand word.
179
+ - The previous iOS REST upload reinvented Apple's `/v1/buildUploads`
180
+ payload shape and got it wrong (Apple rejected with
181
+ `ENTITY_ERROR.ATTRIBUTE.UNKNOWN` on `fileName` / `fileSize`); the
182
+ resulting 409 handler silently mapped every 409 to "build version
183
+ conflict" and masked the real error. Replaced with `xcrun altool`
184
+ shell-out (Apple's canonical CLI).
185
+ - README's "Secure" bullet no longer says "credentials stored locally"
186
+ — was misleading in default vault mode. New copy names the AES-256
187
+ at-rest server encryption and points at the `--local-only` opt-in
188
+ that delivers the literal property.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mysigner (0.1.7)
4
+ mysigner (0.2.0)
5
5
  base64 (~> 0.2)
6
6
  faraday (~> 2.14)
7
7
  faraday-retry (~> 2.2)
@@ -19,8 +19,8 @@ GEM
19
19
  base64
20
20
  nkf
21
21
  rexml
22
- addressable (2.8.7)
23
- public_suffix (>= 2.0.2, < 7.0)
22
+ addressable (2.9.0)
23
+ public_suffix (>= 2.0.2, < 8.0)
24
24
  ast (2.4.3)
25
25
  atomos (0.1.3)
26
26
  base64 (0.3.0)
@@ -32,7 +32,7 @@ GEM
32
32
  rexml
33
33
  declarative (0.0.20)
34
34
  diff-lcs (1.6.2)
35
- faraday (2.14.1)
35
+ faraday (2.14.2)
36
36
  faraday-net_http (>= 2.0, < 3.5)
37
37
  json
38
38
  logger
@@ -66,8 +66,8 @@ GEM
66
66
  signet (>= 0.16, < 2.a)
67
67
  hashdiff (1.2.1)
68
68
  io-console (0.8.1)
69
- json (2.19.3)
70
- jwt (3.1.2)
69
+ json (2.19.5)
70
+ jwt (3.2.0)
71
71
  base64
72
72
  language_server-protocol (3.17.0.5)
73
73
  lint_roller (1.1.0)
@@ -85,7 +85,7 @@ GEM
85
85
  racc
86
86
  plist (3.7.2)
87
87
  prism (1.9.0)
88
- public_suffix (6.0.2)
88
+ public_suffix (7.0.5)
89
89
  racc (1.8.1)
90
90
  rainbow (3.1.1)
91
91
  rake (13.3.0)
data/README.md CHANGED
@@ -27,7 +27,7 @@ Mobile developers spend hours dealing with:
27
27
  ✅ **CI/CD Ready** - Automate builds in GitHub Actions, GitLab CI, etc.
28
28
  ✅ **API-Powered** - Backed by My Signer API for team collaboration
29
29
  ✅ **Smart Version Handling** - Auto-increment version codes for Android
30
- ✅ **Secure** - Token-based authentication, credentials stored locally
30
+ ✅ **Secure** - Token-based auth; signing credentials are AES-256 encrypted at rest on the MySigner server by default, with opt-in `--local-only` mode that keeps Apple `.p8` and Google service-account JSON on your machine
31
31
 
32
32
  ---
33
33
 
@@ -316,6 +316,99 @@ mysigner signing configure --all-targets # Configure all targets
316
316
 
317
317
  ---
318
318
 
319
+ ## Local-only mode (`--local-only` / `MYSIGNER_LOCAL_ONLY`)
320
+
321
+ Local-only mode routes signing-credential auth through your machine instead of the My Signer server. Your Apple `.p8` private key and Google Play service-account JSON live in the macOS Keychain (or an AES-256-GCM-encrypted file on Linux/Windows) and the CLI mints the ASC JWT and Google OAuth token locally at upload time. Activate per command with `--local-only` or globally with `MYSIGNER_LOCAL_ONLY=1`.
322
+
323
+ ```bash
324
+ mysigner --local-only ship appstore
325
+ mysigner --local-only ship play production
326
+ MYSIGNER_LOCAL_ONLY=1 mysigner ship testflight
327
+ ```
328
+
329
+ ### Local-only vs vault (server) mode
330
+
331
+ | | Vault (default) | Local-only |
332
+ | ------------------------------- | -------------------------------------- | ------------------------------------------------ |
333
+ | Holds `.p8` / SA-JSON at rest | My Signer server (CMK-encrypted) | Your macOS Keychain or local AES-256-GCM file |
334
+ | Mints ASC JWT / Google OAuth | Server | CLI, on your machine |
335
+ | Server endpoints used at ship | All (sync, list, builds, submit, etc.) | All except credential-minting and the actual API upload |
336
+ | Multi-machine sync | Yes — log in from anywhere | No — re-onboard on each machine |
337
+ | Team-sharing | Yes — server gates per-user access | No — per-machine only |
338
+ | CI/CD setup | API token in CI secrets | Pre-populate `~/.mysigner/credentials/` from a secret store |
339
+ | Revocation surface | Revoke at server (audit log) | Wipe the Keychain entry / local file |
340
+
341
+ ### What local-only does NOT do (v1)
342
+
343
+ Local-only mode in v1 guards the **API-call-time credentials** (the ASC JWT and the Google OAuth token). The `ship` pipelines still talk to My Signer for orchestration. Be honest with yourself about which of these matter for your threat model:
344
+
345
+ - **`ship appstore --local-only`** still calls My Signer for pre-upload sync, app/build lookup, the post-upload poll loop, and `submit_for_review!`. Only the upload itself goes direct to Apple.
346
+ - **`ship play --local-only`** still calls My Signer for the Android build record, `link_to_app`, release defaults, highest-version-code lookup, **and the keystore download** (via the keystore manager). Only the Play Publishing API call goes direct to Google.
347
+ - **Android keystore is the biggest gap.** The signing keystore is still downloaded from My Signer in local-only mode. v1 does not guard the long-lived Android signing material itself — only the OAuth token used to talk to Google.
348
+ - **Single-account assumption.** Both `ship appstore` and `ship play` pick the first credential in storage order (`LocalCredentials.list.first`). If you have multiple ASC accounts or multiple Google Play SA-JSONs, there is no way to choose between them yet. Multi-account routing (`--asc-account`, `--google-play-account`) is a planned follow-up.
349
+ - **CI/CD non-interactive onboarding.** `mysigner onboard --local-only` is interactive only. For CI you must pre-populate `~/.mysigner/credentials/` from a secret store before invoking the CLI; a non-interactive onboarding mode is a planned follow-up.
350
+
351
+ ### Setup
352
+
353
+ Run the guided flow:
354
+
355
+ ```bash
356
+ mysigner --local-only onboard
357
+ ```
358
+
359
+ You'll be asked whether to set up App Store Connect, Google Play, or both:
360
+
361
+ ```
362
+ 🚀 My Signer Setup (local-only)
363
+ ================================================================================
364
+
365
+ Local-only mode: credentials stay on this machine.
366
+
367
+ Set up App Store Connect credentials now? [Y/n]
368
+
369
+ 📱 App Store Connect (local-only)
370
+
371
+ Path to your .p8 private key: # e.g. ~/Downloads/AuthKey_ABC12345.p8
372
+ Enter your Key ID (e.g., ABC12345): # auto-detected from filename when possible
373
+ Enter your Issuer ID (UUID):
374
+
375
+ Set up Google Play credentials now? [Y/n]
376
+
377
+ 🤖 Google Play (local-only)
378
+
379
+ Path to your service-account JSON: # e.g. ~/Downloads/sa-key.json
380
+ ```
381
+
382
+ Storage:
383
+ - **macOS**: Keychain service `com.mysigner.cli.credentials`, accounts namespaced as `asc:<key_id>` and `google_play:<client_email>`.
384
+ - **Other OSes**: per-credential AES-256-GCM files under `~/.mysigner/credentials/<kind>/<id>`, mode `0600`, encrypted under the same per-machine key as `Config`.
385
+
386
+ ### Daily usage
387
+
388
+ ```bash
389
+ # iOS — local-mint ASC JWT, upload direct to Apple
390
+ mysigner --local-only ship testflight
391
+ mysigner --local-only ship appstore --submit-for-review
392
+
393
+ # Android — local-mint Google OAuth token, call Play Publishing API direct
394
+ mysigner --local-only ship internal --platform android
395
+ mysigner --local-only ship production --platform android
396
+
397
+ # Or set globally for the shell / CI job
398
+ export MYSIGNER_LOCAL_ONLY=1
399
+ mysigner ship testflight
400
+ ```
401
+
402
+ ### Threat model
403
+
404
+ In vault mode, My Signer's CMK / envelope encryption gates access to your `.p8` and SA-JSON at rest on the server, and access is logged and revocable at the org level. In local-only mode the same material sits in your macOS Keychain (or AES-256-GCM file) gated by your machine's user account. The tradeoff is **who you trust**: My Signer's infrastructure vs your own machine. Single-developer teams, regulated industries, and security-paranoid setups generally prefer local-only; collaborative teams generally prefer vault mode for its revocation surface and shared visibility.
405
+
406
+ ### Migration
407
+
408
+ To switch from vault to local-only, run `mysigner --local-only onboard` and re-enter your credentials. The server-stored credentials stay in My Signer but the CLI stops fetching them while `--local-only` / `MYSIGNER_LOCAL_ONLY` is set. To switch back, run `mysigner onboard` (no flag). There is no automated data migration — credentials are re-entered on the new side.
409
+
410
+ ---
411
+
319
412
  ## Configuration
320
413
 
321
414
  My Signer CLI stores configuration in `~/.mysigner/config.yml`:
data/exe/mysigner CHANGED
@@ -3,6 +3,60 @@
3
3
 
4
4
  require 'mysigner'
5
5
 
6
+ # Hoist class_options written BEFORE the subcommand to AFTER it.
7
+ # Thor's `retrieve_command_name` only treats the first non-flag arg as the
8
+ # command — when ARGV starts with a class_option flag like `--local-only`,
9
+ # Thor sees no command, silently dispatches to `help`, and we get
10
+ # `"mysigner help" was called with arguments ["ship", "appstore", ...]`.
11
+ #
12
+ # We avoid hard-coding the flag list by reading
13
+ # `Mysigner::CLI.class_options` at startup. Class-option booleans support
14
+ # Thor's auto-generated `--no-flag` / `--skip-flag` variants too. String/
15
+ # numeric options aren't currently used as class_options here, but if one
16
+ # is added we also recognise `--flag value` (separate-arg form).
17
+ def hoist_leading_class_options!(argv)
18
+ return argv if argv.empty?
19
+
20
+ class_opts = Mysigner::CLI.class_options
21
+ switches = class_opts.each_with_object({}) do |(_, opt), acc|
22
+ Array(opt.switch_name).each { |s| acc[s] = opt }
23
+ opt.aliases.each { |s| acc[s] = opt }
24
+ next unless opt.type == :boolean && opt.switch_name.start_with?('--')
25
+
26
+ base = opt.switch_name.sub(/\A--/, '')
27
+ acc["--no-#{base}"] = opt
28
+ acc["--skip-#{base}"] = opt
29
+ end
30
+
31
+ hoisted = []
32
+ i = 0
33
+ while i < argv.length
34
+ arg = argv[i]
35
+ flag_name = arg.split('=', 2).first
36
+ break unless switches.key?(flag_name)
37
+
38
+ hoisted << arg
39
+ # `--flag value` form: pull the value too, but only when the flag was
40
+ # written WITHOUT `=` AND the option isn't a boolean (booleans don't
41
+ # take a value).
42
+ opt = switches[flag_name]
43
+ if opt.type != :boolean && !arg.include?('=') && argv[i + 1] && !argv[i + 1].start_with?('-')
44
+ hoisted << argv[i + 1]
45
+ i += 1
46
+ end
47
+ i += 1
48
+ end
49
+
50
+ return argv if hoisted.empty?
51
+ # No command word follows the leading flags — leave argv alone so Thor
52
+ # can fall through to its own no-command help dispatch.
53
+ return argv if i >= argv.length
54
+
55
+ command = argv[i]
56
+ rest = argv[(i + 1)..]
57
+ [command, *hoisted, *rest]
58
+ end
59
+
6
60
  # Normalize `mysigner <cmd> [args…] --help` → `mysigner help <cmd>`. Thor
7
61
  # treats extra positional args as a fatal argument-count error, so without
8
62
  # this rewrite users who type `mysigner ship testflight --help` see
@@ -19,4 +73,4 @@ def rewrite_help_flag!(argv)
19
73
  ['help', first]
20
74
  end
21
75
 
22
- Mysigner::CLI.start(rewrite_help_flag!(ARGV))
76
+ Mysigner::CLI.start(rewrite_help_flag!(hoist_leading_class_options!(ARGV)))
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'openssl'
5
+
6
+ module Mysigner
7
+ module Auth
8
+ # Mints an Apple App Store Connect API JWT locally from a .p8 private key.
9
+ #
10
+ # Used by the CLI's local-only mode (epic mysigner-22) so credentials never
11
+ # leave the user's machine. The MySigner server path mints the same shape
12
+ # server-side; this class produces a token equivalent to what the server
13
+ # would return, so downstream ASC callers (e.g. Mysigner::Upload::AscRestUploader)
14
+ # are agnostic to where the token came from.
15
+ #
16
+ # Apple's spec (https://developer.apple.com/documentation/appstoreconnectapi/
17
+ # generating-tokens-for-api-requests):
18
+ # - header: { alg: ES256, kid: <key_id>, typ: JWT }
19
+ # - claims: iss=<issuer_id>, iat=<now>, exp=<iat+TTL>, aud=appstoreconnect-v1
20
+ # - signed ES256 over base64url(header).base64url(claims) with the .p8 EC key
21
+ #
22
+ # Apple caps token lifetime at 20 minutes. We default to 19 minutes so
23
+ # consumers can safely reuse a token throughout a typical ASC upload session
24
+ # without bumping into the cap mid-request.
25
+ class AscJwtMinter
26
+ DEFAULT_TTL = 19 * 60
27
+ AUDIENCE = 'appstoreconnect-v1'
28
+
29
+ def initialize(key_id:, issuer_id:, p8_pem:)
30
+ @key_id = present!(key_id, 'key_id')
31
+ @issuer_id = present!(issuer_id, 'issuer_id')
32
+ @ec_key = parse_ec_key(present!(p8_pem, 'p8_pem'))
33
+ end
34
+
35
+ # Returns a String JWT. `now` is injectable for testability.
36
+ def mint(ttl: DEFAULT_TTL, now: Time.now)
37
+ iat = now.to_i
38
+ payload = {
39
+ iss: @issuer_id,
40
+ iat: iat,
41
+ exp: iat + ttl,
42
+ aud: AUDIENCE
43
+ }
44
+ headers = { kid: @key_id, typ: 'JWT' }
45
+ # JWT.encode with ES256 produces a fixed-width raw r||s signature per
46
+ # JWA RFC 7518, which is what Apple requires.
47
+ JWT.encode(payload, @ec_key, 'ES256', headers)
48
+ end
49
+
50
+ private
51
+
52
+ def present!(value, name)
53
+ raise ArgumentError, "#{name} must be present" if value.nil? || value.to_s.empty?
54
+
55
+ value
56
+ end
57
+
58
+ def parse_ec_key(p8_pem)
59
+ key = OpenSSL::PKey.read(p8_pem.to_s)
60
+ raise ArgumentError, "p8_pem must be an EC private key (got #{key.class})" unless key.is_a?(OpenSSL::PKey::EC)
61
+
62
+ key
63
+ rescue OpenSSL::PKey::PKeyError => e
64
+ raise ArgumentError, "p8_pem could not be parsed as a private key: #{e.message}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'googleauth'
6
+
7
+ module Mysigner
8
+ module Auth
9
+ # Mints a short-lived Google OAuth2 access_token from a service-account
10
+ # JSON key, locally — without going through the MySigner server. The
11
+ # service-account JSON never leaves the caller's process.
12
+ #
13
+ # Delegates the JWT mint + assertion exchange + caching to googleauth
14
+ # (Google::Auth::ServiceAccountCredentials), which is already a runtime
15
+ # dependency for the Play Publishing API client.
16
+ class GoogleOauthMinter
17
+ DEFAULT_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
18
+ REQUIRED_KEYS = %w[type client_email private_key project_id].freeze
19
+
20
+ # @param service_account_json [String, Hash] the raw JSON string or an
21
+ # already-parsed Hash. Validated for required keys.
22
+ # @raise [ArgumentError] when input is nil/empty, unparseable, or missing
23
+ # any of {REQUIRED_KEYS}.
24
+ def initialize(service_account_json)
25
+ @json_hash = coerce_to_hash(service_account_json)
26
+ validate_required_keys!(@json_hash)
27
+ end
28
+
29
+ # @param scope [String] OAuth2 scope. Defaults to the Play Publishing
30
+ # scope used by `ship play`. Override for other Google APIs.
31
+ # @return [String] a non-empty access_token (e.g. "ya29...").
32
+ def mint(scope: DEFAULT_SCOPE)
33
+ token_data = fetch_token(scope)
34
+ token_data['access_token'] || token_data[:access_token]
35
+ end
36
+
37
+ # Variant that returns both the token and its expiry timestamp, for
38
+ # callers that want to manage caching themselves.
39
+ # @return [TokenWithExpiry] members :access_token, :expires_at (Time or nil)
40
+ def mint_with_expiry(scope: DEFAULT_SCOPE)
41
+ token_data = fetch_token(scope)
42
+ access_token = token_data['access_token'] || token_data[:access_token]
43
+ expires_in = token_data['expires_in'] || token_data[:expires_in]
44
+ expires_at = expires_in ? Time.now + expires_in.to_i : nil
45
+ TokenWithExpiry.new(access_token, expires_at)
46
+ end
47
+
48
+ TokenWithExpiry = Struct.new(:access_token, :expires_at)
49
+
50
+ private
51
+
52
+ def fetch_token(scope)
53
+ creds = Google::Auth::ServiceAccountCredentials.make_creds(
54
+ json_key_io: StringIO.new(JSON.dump(@json_hash)),
55
+ scope: scope
56
+ )
57
+ creds.fetch_access_token!
58
+ end
59
+
60
+ def coerce_to_hash(input)
61
+ raise ArgumentError, 'service_account_json is required' if input.nil?
62
+
63
+ case input
64
+ when Hash
65
+ raise ArgumentError, 'service_account_json hash is empty' if input.empty?
66
+
67
+ input.transform_keys(&:to_s)
68
+ when String
69
+ raise ArgumentError, 'service_account_json is required' if input.strip.empty?
70
+
71
+ begin
72
+ JSON.parse(input)
73
+ rescue JSON::ParserError => e
74
+ raise ArgumentError, "service_account_json is not valid JSON: #{e.message}"
75
+ end
76
+ else
77
+ raise ArgumentError, "service_account_json must be a String or Hash, got #{input.class}"
78
+ end
79
+ end
80
+
81
+ def validate_required_keys!(hash)
82
+ missing = REQUIRED_KEYS.reject { |key| hash[key].is_a?(String) && !hash[key].strip.empty? }
83
+ return if missing.empty?
84
+
85
+ raise ArgumentError, "service_account_json is missing required keys: #{missing.join(', ')}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -15,7 +15,6 @@ module Mysigner
15
15
  end
16
16
 
17
17
  def call
18
- return if ENV['MYSIGNER_USE_LEGACY_ASC'] == '1'
19
18
  return if File.exist?(marker_path)
20
19
 
21
20
  LEGACY_DIRS.each do |dir|