standard_id-apple 0.5.0 → 0.6.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: 3886bf49958b7ea2704e0491b0e7225c7f82b0d80ca7ab0dcca192b69e7bc1fa
4
- data.tar.gz: 804f776780574d8457d6037148148c823a1011ffe3e68a8b84362c821f61c623
3
+ metadata.gz: be7fc30fab9732c5874d6d9672193130821d23251e1d838bfaf952851f988a6f
4
+ data.tar.gz: '0529585c83478d6ce4f11a48dbc2fb54548db01ff75f46a50f3f77673b8bc234'
5
5
  SHA512:
6
- metadata.gz: f124007a84b444fd05c29af8c990cb878a1f9c63a2947309ca719f7e8bda6c0cdbd78d5d793ad404257dca8115ee8b59e5fdc28e320825db75267266826ed261
7
- data.tar.gz: f90487e071ce29728dab12aa31cee722828e54056b90541ca606ea3e54e4728cba7d7f8458fbfe966212b3b2ce1f99afdda2ead034d7c959c7c17ee71fd8ceae
6
+ metadata.gz: 0fcea150d715a749d08fbdea281f45e790901a4af7e9cacd090492b66f5e6c633fa3e249164d4613309c780350ee1d8ba3c674b7e685573862f1177f89d8dc1e
7
+ data.tar.gz: 3f131c47f4e7ebe1021a967d3426356efdb80b8eeeed86ded1c3f4f5fb5210257ca58fe95a7292a9246404679cb83a0d38e30be5977ffed50a021ffaa5af2d4a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-24
11
+
12
+ Adopts the provider-plugin API of standard_id 0.42.
13
+
14
+ ### Upgrade
15
+
16
+ - **Requires `standard_id` 0.42** (`~> 0.42`, was `>= 0.29, < 1.0`). Bump both
17
+ together.
18
+ - **Rename `APPLE_PRIVATE_KEY_PEM` to `APPLE_PRIVATE_KEY`** when convenient.
19
+ Initializers from the 0.5.0 generator assign `ENV["APPLE_PRIVATE_KEY_PEM"]`
20
+ explicitly and keep working unchanged; the old name is also read as a
21
+ deprecated ENV fallback (see Deprecated).
22
+ - **Expect a boot warning if Apple is half-configured.** With
23
+ `apple_client_id` set and any of `apple_team_id` / `apple_key_id` /
24
+ `apple_private_key` blank, standard_id now logs a warning at boot (raises in
25
+ production under `c.social.provider_misconfiguration = :raise`). sidekick-web
26
+ can drop the gated `APPLE_PRIVATE_KEY` / `APPLE_KEY_ID` / `APPLE_TEAM_ID`
27
+ entries in `config/initializers/standard_health.rb` in favour of that
28
+ setting, or keep them for the health report.
29
+ - **Anyone rescuing on message text:** several messages changed (see Changed).
30
+ `StandardId::Apple::Railtie` no longer exists.
31
+
32
+ ### Added
33
+
34
+ - **Required config fields.** `apple_private_key`, `apple_key_id` and
35
+ `apple_team_id` are declared `required: true`, so
36
+ `StandardId::Providers::Apple.configuration_errors` and standard_id's boot
37
+ check report them whenever `apple_client_id` (the enabling field) is set —
38
+ the two-stage check sidekick-web rebuilt by hand. `apple_mobile_client_id`
39
+ stays optional: the native `id_token` flow needs no signing key.
40
+ - **ENV fallback** through standard_id 0.42: `APPLE_CLIENT_ID`,
41
+ `APPLE_MOBILE_CLIENT_ID`, `APPLE_PRIVATE_KEY`, `APPLE_KEY_ID`, `APPLE_TEAM_ID`.
42
+ - **JWKS caching.** Apple's key set is cached in-process for an hour
43
+ (`JWKS_CACHE_TTL`) instead of being downloaded on every sign-in. A token whose
44
+ `kid` is not in the cached set triggers one refetch — Apple rotated — no more
45
+ than once a minute (`JWKS_MIN_REFRESH_INTERVAL`), so tokens with made-up
46
+ `kid`s cannot make this process hammer Apple. `reset_jwks_cache!` drops it.
47
+ - Specs for `resolve_params`, `skip_csrf?`, `supports_mobile_callback?` /
48
+ `flow_for`, nonce handling, JWKS caching and configuration, plus standard_id's
49
+ `"a registered StandardId provider"` shared example.
50
+
51
+ ### Changed
52
+
53
+ - **The JWKS is fetched through `StandardId::HttpClient`** — 5s open / 10s read
54
+ timeouts and the private/internal-address guard — instead of a bare
55
+ `Net::HTTP.get_response` with Ruby's default 60s timeouts. HttpClient has no
56
+ public plain GET, so this calls its `validate_url!` / `start_connection`
57
+ directly; CI's compat job catches it if those change.
58
+ - **Nonce mismatches no longer leak the nonce.** The message was
59
+ `ID token nonce mismatch. Expected: <nonce>, got: <nonce>`; it is now
60
+ standard_id's `ID token nonce mismatch`, and the comparison is constant-time.
61
+ - **The duplicated helpers are gone** in favour of standard_id's
62
+ `Providers::Base`: `rescue_to_oauth_error`, `verify_nonce!`,
63
+ `build_authorization_url`, `extract_tokens` (the private
64
+ `extract_token_payload` is removed). `lib/standard_id/apple/railtie.rb` is
65
+ replaced by `StandardId::Providers.plugin_railtie(:apple, ...)`.
66
+ - **Error messages**, now consistently Apple-prefixed and never echoing a `kid`
67
+ or nonce:
68
+ - `Either code or id_token must be provided` → `Apple sign-in requires a code or an id_token`
69
+ - `Access token login flow is not supported for Apple` → `Apple sign-in does not support the access token flow`
70
+ - `Missing authorization code` → `Apple authorization code is missing`
71
+ - `Missing id_token` → `Apple id_token is missing`
72
+ - `Apple response missing id_token` → `Apple token response is missing id_token`
73
+ - `Apple OAuth credentials are incomplete` → `... are incomplete: <fields> not set`
74
+ - `Failed to exchange Apple authorization code: <error>` now falls back to
75
+ `HTTP <status>` when Apple's body names no error (it printed nothing).
76
+ - `JWK with kid '<kid>' not found in Apple's JWKS` → `Invalid Apple ID token: signing key not found in Apple's JWKS`
77
+ - `Failed to fetch JWK: ...` → `Failed to fetch Apple JWKS: ...`. A non-2xx
78
+ JWKS response is now `StandardId::OAuthError` (an upstream failure) rather
79
+ than `InvalidRequestError`.
80
+ - Install generator and README use the canonical `APPLE_PRIVATE_KEY`, and
81
+ document the ENV fallback, required fields and flows.
82
+
83
+ ### Deprecated
84
+
85
+ - **`APPLE_PRIVATE_KEY_PEM`** as an ENV source for `apple_private_key`. Read only
86
+ when the field is never assigned and `APPLE_PRIVATE_KEY` is unset, with one
87
+ warning per process through `StandardId.deprecator`.
88
+
89
+ ### Removed
90
+
91
+ - `StandardId::Apple::Railtie` (replaced by the Railtie `plugin_railtie`
92
+ defines, `StandardId::Providers::Railties::Apple`).
93
+
10
94
  ## [0.5.0] - 2026-07-31
11
95
 
12
96
  ### Added
data/README.md CHANGED
@@ -4,7 +4,7 @@ This gem extracts the Apple OAuth provider from the core [`standard_id`](https:/
4
4
 
5
5
  ## Installation
6
6
 
7
- Add the gem next to `standard_id`:
7
+ Requires `standard_id` 0.42 or later. Add the gem next to `standard_id`:
8
8
 
9
9
  ```ruby
10
10
  # Gemfile
@@ -41,18 +41,72 @@ configuring by hand. Configure Apple credentials via the StandardId
41
41
  configuration block, in the `social` scope:
42
42
 
43
43
  ```ruby
44
- # config/initializers/standard_id.rb
44
+ # config/initializers/standard_id_apple.rb
45
45
  StandardId.configure do |config|
46
46
  config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
47
47
  config.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"] # optional
48
48
  config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
49
49
  config.social.apple_key_id = ENV["APPLE_KEY_ID"]
50
- config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY_PEM"]
50
+ config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
51
51
  end
52
52
  ```
53
53
 
54
54
  With those values in place, StandardId routes such as `/auth/callback/apple` continue to function using this provider gem.
55
55
 
56
+ ### ENV fallback
57
+
58
+ On `standard_id` 0.42+, a field you never assign falls back to the ENV
59
+ variable named after it, upper-cased:
60
+
61
+ | Field | ENV variable |
62
+ |---|---|
63
+ | `apple_client_id` | `APPLE_CLIENT_ID` |
64
+ | `apple_mobile_client_id` | `APPLE_MOBILE_CLIENT_ID` |
65
+ | `apple_team_id` | `APPLE_TEAM_ID` |
66
+ | `apple_key_id` | `APPLE_KEY_ID` |
67
+ | `apple_private_key` | `APPLE_PRIVATE_KEY` (deprecated fallback: `APPLE_PRIVATE_KEY_PEM`) |
68
+
69
+ So with those variables set the block above is optional. Explicit
70
+ configuration, even `nil`, always wins. `APPLE_PRIVATE_KEY_PEM` — the name
71
+ install generators before 0.6.0 wrote — is still read when
72
+ `APPLE_PRIVATE_KEY` is unset and the field is never assigned, with a
73
+ deprecation warning; rename it. (An initializer that assigns
74
+ `ENV["APPLE_PRIVATE_KEY_PEM"]` explicitly keeps working as-is.)
75
+
76
+ ### Required fields and the boot check
77
+
78
+ `apple_client_id` (the web Services ID) switches the provider on — it is what
79
+ `StandardId.social_provider_enabled?(:apple)` and the `apple_enabled` Inertia
80
+ prop report. While it is set, `apple_team_id`, `apple_key_id` and
81
+ `apple_private_key` are required: the code exchange signs its `client_secret`
82
+ with them, so without them the flow starts, the user authenticates with Apple,
83
+ and only then does the callback fail. StandardId checks this once every plugin
84
+ has registered:
85
+
86
+ ```ruby
87
+ StandardId::Providers::Apple.configuration_errors
88
+ # => ["apple_private_key is required when apple_client_id is set"]
89
+
90
+ config.social.provider_misconfiguration = :raise # fail a production boot instead of warning
91
+ ```
92
+
93
+ `apple_mobile_client_id` gates nothing: the native `id_token` flow verifies the
94
+ token against Apple's JWKS and needs no signing key. A native-only app (mobile
95
+ client ID set, `apple_client_id` not) is therefore "not enabled" for the web
96
+ UI while its native sign-in keeps working.
97
+
98
+ ### Flows
99
+
100
+ | Flow | Audience (`client_id`) | Needs signing key |
101
+ |---|---|---|
102
+ | Web (`/auth/callback/apple`, form_post, CSRF skipped) | `apple_client_id` | yes |
103
+ | Native `id_token` (`/api/oauth/callback/apple`) | `apple_mobile_client_id` | no |
104
+ | Web flow on Android etc. (`flow=web` on the API callback, redirects back into the app) | `apple_client_id` | yes |
105
+
106
+ Apple's signing keys (JWKS) are fetched through `StandardId::HttpClient`
107
+ (timeouts, private-address guard) and cached in-process for an hour; a token
108
+ signed with an unknown key triggers one early refetch, at most once a minute.
109
+
56
110
  ### Two corrections to earlier versions of this section
57
111
 
58
112
  **The `social.` prefix.** This section previously showed the flat form
@@ -92,7 +146,18 @@ without this gem in your Gemfile**, on any `standard_id` version. Configuring
92
146
 
93
147
  ## Testing
94
148
 
95
- Run the spec suite:
149
+ In a host app, pin the plugin's registration with standard_id's shared
150
+ example:
151
+
152
+ ```ruby
153
+ require "standard_id/testing"
154
+
155
+ RSpec.describe "StandardId social providers" do
156
+ it_behaves_like "a registered StandardId provider", :apple
157
+ end
158
+ ```
159
+
160
+ Run this gem's spec suite:
96
161
 
97
162
  ```bash
98
163
  bundle exec rspec
@@ -65,9 +65,12 @@ module StandardId
65
65
  say " APPLE_MOBILE_CLIENT_ID the iOS bundle ID — optional, web-only apps skip it"
66
66
  say " APPLE_TEAM_ID the 10-character Apple Developer team ID"
67
67
  say " APPLE_KEY_ID the key ID of the Sign In with Apple private key"
68
- say " APPLE_PRIVATE_KEY_PEM the .p8 private key contents, PEM, newlines intact"
68
+ say " APPLE_PRIVATE_KEY the .p8 private key contents, PEM, newlines intact"
69
69
  say ""
70
- say "APPLE_PRIVATE_KEY_PEM is multi-line. Most secret stores flatten it to"
70
+ say "APPLE_TEAM_ID, APPLE_KEY_ID and APPLE_PRIVATE_KEY are required whenever"
71
+ say "APPLE_CLIENT_ID is set; StandardId checks for them at boot."
72
+ say ""
73
+ say "APPLE_PRIVATE_KEY is multi-line. Most secret stores flatten it to"
71
74
  say "literal \\n — if sign-in fails on a JWT signing error, that is why."
72
75
  say "=" * 79
73
76
  say ""
@@ -13,17 +13,17 @@
13
13
  # routes a unique unqualified name to its owning scope, but it stops working
14
14
  # the day another scope declares a colliding name. Use `config.social.`.
15
15
  #
16
- # On standard_id <= 0.32.0 these fields were declared from this gem's Railtie
17
- # `after_initialize`, which runs AFTER config/initializers — so the plain block
18
- # below raised at boot and had to be wrapped:
16
+ # ENV: on standard_id >= 0.42 a field this file does not assign falls back to
17
+ # the ENV variable named after it, upper-cased — APPLE_CLIENT_ID,
18
+ # APPLE_MOBILE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY — which
19
+ # are the names used below. The assignments are kept so the wiring is visible;
20
+ # delete any you prefer to leave to the fallback, or point one at another
21
+ # source (e.g. Rails credentials).
19
22
  #
20
- # Rails.application.config.after_initialize do
21
- # StandardId.configure { |config| config.social.apple_client_id = ... }
22
- # end
23
- #
24
- # standard_id >= 0.33.0 declares every loaded provider's fields before
25
- # `:load_config_initializers`, so the plain form is correct. Existing wrappers
26
- # keep working and need no change.
23
+ # apple_client_id switches the web flow on. While it is set, StandardId's boot
24
+ # check requires apple_team_id, apple_key_id and apple_private_key (warns by
25
+ # default; `config.social.provider_misconfiguration = :raise` fails a
26
+ # production boot instead).
27
27
  StandardId.configure do |config|
28
28
  config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
29
29
 
@@ -38,5 +38,6 @@ StandardId.configure do |config|
38
38
  # The .p8 contents in PEM form, newlines intact. Secret stores that flatten
39
39
  # multi-line values to literal \n produce a key that parses but fails to
40
40
  # sign — surfacing as a JWT error at callback time, not at boot.
41
- config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY_PEM"]
41
+ # (Installs before standard_id-apple 0.6.0 used APPLE_PRIVATE_KEY_PEM.)
42
+ config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
42
43
  end
@@ -1,7 +1,7 @@
1
- require "uri"
2
- require "net/http"
3
1
  require "json"
4
2
  require "jwt"
3
+ require "net/http"
4
+ require "openssl"
5
5
 
6
6
  module StandardId
7
7
  module Providers
@@ -17,6 +17,22 @@ module StandardId
17
17
  response_mode: DEFAULT_RESPONSE_MODE
18
18
  }.freeze
19
19
 
20
+ # Apple's signing keys rotate rarely; an unknown `kid` forces an early
21
+ # refetch (see #fetch_jwk), so a long TTL costs nothing on rotation.
22
+ JWKS_CACHE_TTL = 3600
23
+ # Floor between forced refetches, so a stream of ID tokens carrying
24
+ # made-up `kid`s cannot turn this process into a request amplifier
25
+ # against Apple.
26
+ JWKS_MIN_REFRESH_INTERVAL = 60
27
+
28
+ # Pre-0.6.0 install generators wired `apple_private_key` to this
29
+ # variable. Still read, with a deprecation warning, when the canonical
30
+ # APPLE_PRIVATE_KEY is unset and the host never assigns the field.
31
+ LEGACY_PRIVATE_KEY_ENV = "APPLE_PRIVATE_KEY_PEM".freeze
32
+
33
+ @jwks_mutex = Mutex.new
34
+ @jwks_cache = nil
35
+
20
36
  class << self
21
37
  def provider_name
22
38
  "apple"
@@ -29,18 +45,12 @@ module StandardId
29
45
  def authorization_url(state:, redirect_uri:, **options)
30
46
  ensure_basic_credentials!
31
47
 
32
- query = {
48
+ build_authorization_url(
49
+ endpoint: AUTH_ENDPOINT,
33
50
  client_id: StandardId.config.apple_client_id,
34
- redirect_uri:,
35
- response_type: "code",
36
- state:
37
- }
38
-
39
- supported_authorization_params.each do |param|
40
- query[param] = options[param] || AUTHORIZATION_PARAM_DEFAULTS[param]
41
- end
42
-
43
- "#{AUTH_ENDPOINT}?#{URI.encode_www_form(query.compact)}"
51
+ redirect_uri:, state:, options:,
52
+ defaults: AUTHORIZATION_PARAM_DEFAULTS
53
+ )
44
54
  end
45
55
 
46
56
  def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, nonce: nil, **options)
@@ -54,19 +64,32 @@ module StandardId
54
64
  elsif code.present?
55
65
  exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, client_id: client_id, nonce: nonce)
56
66
  elsif access_token.present?
57
- raise StandardId::InvalidRequestError, "Access token login flow is not supported for Apple"
67
+ raise StandardId::InvalidRequestError, "Apple sign-in does not support the access token flow"
58
68
  else
59
- raise StandardId::InvalidRequestError, "Either code or id_token must be provided"
69
+ raise StandardId::InvalidRequestError, "Apple sign-in requires a code or an id_token"
60
70
  end
61
71
  end
62
72
 
73
+ # `apple_client_id` (the web Services ID) switches the provider on —
74
+ # it is the enabling field, so StandardId's boot check reports the
75
+ # three signing credentials below whenever it is set. They are what
76
+ # the code exchange needs to sign its client_secret; without them the
77
+ # web flow starts fine and only fails at the callback, after the user
78
+ # has authenticated with Apple.
79
+ #
80
+ # `apple_mobile_client_id` gates nothing: the native id_token flow
81
+ # verifies against Apple's JWKS and needs no signing key.
82
+ #
83
+ # ENV fallbacks (standard_id >= 0.42) use the upper-cased field names:
84
+ # APPLE_CLIENT_ID, APPLE_MOBILE_CLIENT_ID, APPLE_PRIVATE_KEY,
85
+ # APPLE_KEY_ID, APPLE_TEAM_ID.
63
86
  def config_schema
64
87
  {
65
88
  apple_client_id: { type: :string, default: nil },
66
89
  apple_mobile_client_id: { type: :string, default: nil },
67
- apple_private_key: { type: :string, default: nil },
68
- apple_key_id: { type: :string, default: nil },
69
- apple_team_id: { type: :string, default: nil }
90
+ apple_private_key: { type: :string, default: -> { legacy_private_key_from_env }, required: true },
91
+ apple_key_id: { type: :string, default: nil, required: true },
92
+ apple_team_id: { type: :string, default: nil, required: true }
70
93
  }
71
94
  end
72
95
 
@@ -74,14 +97,19 @@ module StandardId
74
97
  DEFAULT_SCOPE
75
98
  end
76
99
 
100
+ # Apple posts the web callback (response_mode=form_post).
77
101
  def skip_csrf?
78
102
  true
79
103
  end
80
104
 
105
+ # Android and other non-Apple platforms sign in through Apple's web
106
+ # flow and need the server to redirect back into the app.
81
107
  def supports_mobile_callback?
82
108
  true
83
109
  end
84
110
 
111
+ # The web flow authenticates against the Services ID, the native flow
112
+ # against the app's bundle ID.
85
113
  def resolve_params(params, context: {})
86
114
  flow = context[:flow] || :web
87
115
  client_id = flow == :mobile ? StandardId.config.apple_mobile_client_id : StandardId.config.apple_client_id
@@ -90,83 +118,71 @@ module StandardId
90
118
  end
91
119
 
92
120
  def exchange_code_for_user_info(code:, redirect_uri:, client_id: StandardId.config.apple_client_id, nonce: nil)
93
- ensure_full_credentials!(client_id: client_id)
94
- raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
95
-
96
- token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
97
- client_id: client_id,
98
- client_secret: generate_client_secret(client_id: client_id),
99
- code: code,
100
- grant_type: "authorization_code",
101
- redirect_uri: redirect_uri
102
- })
103
-
104
- unless token_response.is_a?(Net::HTTPSuccess)
105
- error_body = begin
106
- JSON.parse(token_response.body)
107
- rescue StandardError
108
- {}
121
+ rescue_to_oauth_error do
122
+ ensure_full_credentials!(client_id: client_id)
123
+ raise StandardId::InvalidRequestError, "Apple authorization code is missing" if code.blank?
124
+
125
+ token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
126
+ client_id: client_id,
127
+ client_secret: generate_client_secret(client_id: client_id),
128
+ code: code,
129
+ grant_type: "authorization_code",
130
+ redirect_uri: redirect_uri
131
+ })
132
+
133
+ unless token_response.is_a?(Net::HTTPSuccess)
134
+ raise StandardId::InvalidRequestError,
135
+ "Failed to exchange Apple authorization code: #{error_reason(token_response)}"
109
136
  end
110
- raise StandardId::InvalidRequestError, "Failed to exchange Apple authorization code: #{error_body["error"]}"
111
- end
112
-
113
- parsed_token = JSON.parse(token_response.body)
114
- id_token = parsed_token["id_token"]
115
- raise StandardId::InvalidRequestError, "Apple response missing id_token" if id_token.blank?
116
-
117
- tokens = extract_token_payload(parsed_token)
118
- user_info = verify_id_token(id_token: id_token, client_id: client_id, nonce: nonce)
119
137
 
120
- build_response(user_info, tokens: tokens)
121
- rescue StandardError => e
122
- raise e if e.is_a?(StandardId::OAuthError)
138
+ parsed_token = JSON.parse(token_response.body)
139
+ id_token = parsed_token["id_token"]
140
+ raise StandardId::InvalidRequestError, "Apple token response is missing id_token" if id_token.blank?
123
141
 
124
- raise StandardId::OAuthError, e.message, cause: e
142
+ user_info = verify_id_token(id_token: id_token, client_id: client_id, nonce: nonce)
143
+ build_response(user_info, tokens: extract_tokens(parsed_token))
144
+ end
125
145
  end
126
146
 
127
147
  def verify_id_token(id_token:, client_id: StandardId.config.apple_client_id, nonce: nil)
128
- raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
129
- raise StandardId::InvalidRequestError, "Apple client_id is not configured" if client_id.blank?
130
-
131
- decoded_token = JWT.decode(id_token, nil, false)
132
- header = decoded_token[1]
133
-
134
- jwk = fetch_jwk(kid: header["kid"])
135
-
136
- verified_payload, = JWT.decode(
137
- id_token,
138
- jwk.public_key,
139
- true,
140
- algorithms: ["RS256"],
141
- iss: ISSUER,
142
- verify_iss: true,
143
- aud: client_id,
144
- verify_aud: true
145
- )
148
+ rescue_to_oauth_error do
149
+ raise StandardId::InvalidRequestError, "Apple id_token is missing" if id_token.blank?
150
+ raise StandardId::InvalidRequestError, "Apple client_id is not configured" if client_id.blank?
151
+
152
+ _unverified_payload, header = JWT.decode(id_token, nil, false)
153
+ jwk = fetch_jwk(kid: header["kid"])
154
+
155
+ verified_payload, = JWT.decode(
156
+ id_token,
157
+ jwk.public_key,
158
+ true,
159
+ algorithms: ["RS256"],
160
+ iss: ISSUER,
161
+ verify_iss: true,
162
+ aud: client_id,
163
+ verify_aud: true
164
+ )
146
165
 
147
- # Validate nonce if provided (web flow with server-generated nonce)
148
- if nonce.present?
149
- token_nonce = verified_payload["nonce"]
150
- if token_nonce != nonce
151
- raise StandardId::InvalidRequestError,
152
- "ID token nonce mismatch. Expected: #{nonce}, got: #{token_nonce}"
153
- end
166
+ # Web flow with a server-generated nonce; constant-time, and the
167
+ # error never echoes either value.
168
+ verify_nonce!(expected: nonce, actual: verified_payload["nonce"])
169
+
170
+ {
171
+ "sub" => verified_payload["sub"],
172
+ "email" => verified_payload["email"],
173
+ "email_verified" => verified_payload["email_verified"],
174
+ "is_private_email" => verified_payload["is_private_email"]
175
+ }.compact
176
+ rescue JWT::InvalidAudError => e
177
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token audience: #{e.message}"
178
+ rescue JWT::DecodeError => e
179
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token: #{e.message}"
154
180
  end
181
+ end
155
182
 
156
- {
157
- "sub" => verified_payload["sub"],
158
- "email" => verified_payload["email"],
159
- "email_verified" => verified_payload["email_verified"],
160
- "is_private_email" => verified_payload["is_private_email"]
161
- }.compact
162
- rescue JWT::InvalidAudError => e
163
- raise StandardId::InvalidRequestError, "Invalid Apple ID token audience: #{e.message}"
164
- rescue JWT::DecodeError => e
165
- raise StandardId::InvalidRequestError, "Invalid Apple ID token: #{e.message}"
166
- rescue StandardError => e
167
- raise e if e.is_a?(StandardId::OAuthError)
168
-
169
- raise StandardId::OAuthError, e.message, cause: e
183
+ # Drop the in-process JWKS cache (tests, or after a known rotation).
184
+ def reset_jwks_cache!
185
+ @jwks_mutex.synchronize { @jwks_cache = nil }
170
186
  end
171
187
 
172
188
  private
@@ -177,18 +193,18 @@ module StandardId
177
193
  raise StandardId::InvalidRequestError, "Apple OAuth is not configured"
178
194
  end
179
195
 
196
+ # The same fields StandardId's boot check reports (required_config_fields),
197
+ # checked again at the point of use because the mobile code exchange
198
+ # can run with only apple_mobile_client_id set. Names fields, never
199
+ # values.
180
200
  def ensure_full_credentials!(client_id: nil)
181
201
  ensure_basic_credentials!(client_id: client_id)
182
202
 
183
- required = [
184
- StandardId.config.apple_private_key,
185
- StandardId.config.apple_key_id,
186
- StandardId.config.apple_team_id
187
- ]
188
-
189
- return unless required.any?(&:blank?)
203
+ missing = required_config_fields.select { |field| config_value(field).blank? }
204
+ return if missing.empty?
190
205
 
191
- raise StandardId::InvalidRequestError, "Apple OAuth credentials are incomplete"
206
+ raise StandardId::InvalidRequestError,
207
+ "Apple OAuth credentials are incomplete: #{missing.join(', ')} not set"
192
208
  end
193
209
 
194
210
  def generate_client_secret(client_id: StandardId.config.apple_client_id)
@@ -209,32 +225,77 @@ module StandardId
209
225
  JWT.encode(payload, private_key, "ES256", header)
210
226
  end
211
227
 
228
+ # Look `kid` up in Apple's JWKS, served from an in-process cache
229
+ # (JWKS_CACHE_TTL). An unknown `kid` refetches once — Apple may have
230
+ # rotated — subject to JWKS_MIN_REFRESH_INTERVAL.
212
231
  def fetch_jwk(kid:)
213
- uri = URI(JWKS_URI)
214
- jwks_response = Net::HTTP.get_response(uri)
232
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token: header has no kid" if kid.blank?
233
+
234
+ jwk_data = find_jwk(jwks_keys, kid) || find_jwk(jwks_keys(refresh: true), kid)
235
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token: signing key not found in Apple's JWKS" unless jwk_data
236
+
237
+ JWT::JWK.import(jwk_data)
238
+ end
239
+
240
+ def find_jwk(keys, kid)
241
+ keys.find { |key| key["kid"] == kid }
242
+ end
243
+
244
+ def jwks_keys(refresh: false)
245
+ @jwks_mutex.synchronize do
246
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ cache = @jwks_cache
248
+
249
+ stale = cache.nil? || now - cache[:fetched_at] > JWKS_CACHE_TTL
250
+ forced = refresh && cache && now - cache[:fetched_at] >= JWKS_MIN_REFRESH_INTERVAL
251
+ if stale || forced
252
+ @jwks_cache = cache = { keys: download_jwks, fetched_at: now }
253
+ end
215
254
 
216
- unless jwks_response.is_a?(Net::HTTPSuccess)
217
- raise StandardId::InvalidRequestError, "Failed to fetch Apple JWKS"
255
+ cache[:keys]
218
256
  end
257
+ end
219
258
 
220
- jwks_data = JSON.parse(jwks_response.body)
221
- jwk_data = jwks_data["keys"].find { |key| key["kid"] == kid }
259
+ # Fetched through StandardId::HttpClient's connection setup, so the
260
+ # request gets its timeouts and its private/internal-address guard.
261
+ # HttpClient has no public plain GET (only get_with_bearer, which would
262
+ # send an empty Authorization header), hence the two private calls.
263
+ def download_jwks
264
+ rescue_to_oauth_error("Failed to fetch Apple JWKS") do
265
+ uri, resolved_ip = HttpClient.send(:validate_url!, JWKS_URI)
266
+ response = HttpClient.send(:start_connection, uri, resolved_ip: resolved_ip) do |http|
267
+ http.request(Net::HTTP::Get.new(uri))
268
+ end
269
+ raise StandardId::OAuthError, "Failed to fetch Apple JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
222
270
 
223
- raise StandardId::InvalidRequestError, "JWK with kid '#{kid}' not found in Apple's JWKS" unless jwk_data
271
+ keys = JSON.parse(response.body)["keys"]
272
+ raise StandardId::OAuthError, "Failed to fetch Apple JWKS: response has no keys" unless keys.is_a?(Array)
224
273
 
225
- JWT::JWK.import(jwk_data)
226
- rescue StandardError => e
227
- raise e if e.is_a?(StandardId::OAuthError)
274
+ keys
275
+ end
276
+ end
228
277
 
229
- raise StandardId::OAuthError, "Failed to fetch JWK: #{e.message}"
278
+ def error_reason(response)
279
+ body = JSON.parse(response.body.to_s)
280
+ reason = body["error"] if body.is_a?(Hash)
281
+ reason.presence || "HTTP #{response.code}"
282
+ rescue JSON::ParserError
283
+ "HTTP #{response.code}"
230
284
  end
231
285
 
232
- def extract_token_payload(parsed_token)
233
- {
234
- access_token: parsed_token["access_token"],
235
- refresh_token: parsed_token["refresh_token"],
236
- id_token: parsed_token["id_token"]
237
- }.compact
286
+ def legacy_private_key_from_env
287
+ value = ENV[LEGACY_PRIVATE_KEY_ENV]
288
+ return nil if value.blank?
289
+
290
+ # An unassigned field's default is re-evaluated on read; warn once.
291
+ return value if @legacy_private_key_warned
292
+
293
+ @legacy_private_key_warned = true
294
+ StandardId.deprecator.warn(
295
+ "standard_id-apple: reading apple_private_key from #{LEGACY_PRIVATE_KEY_ENV} is deprecated. " \
296
+ "Rename the variable to APPLE_PRIVATE_KEY (or assign config.social.apple_private_key explicitly)."
297
+ )
298
+ value
238
299
  end
239
300
  end
240
301
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StandardId
4
4
  module Apple
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -1,5 +1,10 @@
1
1
  require "active_support/core_ext/numeric/time"
2
2
  require "active_support/core_ext/hash/indifferent_access"
3
3
  require "standard_id"
4
+ require "standard_id/apple/version"
4
5
  require "standard_id/apple/providers/apple"
5
- require "standard_id/apple/railtie" if defined?(Rails)
6
+
7
+ # Registers the provider from a Railtie's after_initialize (a no-op outside
8
+ # Rails). Its config fields are declared earlier, before config/initializers,
9
+ # by standard_id's own engine initializer.
10
+ StandardId::Providers.plugin_railtie(:apple, "StandardId::Providers::Apple")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id-apple
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -47,22 +47,16 @@ dependencies:
47
47
  name: standard_id
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
- - - ">="
51
- - !ruby/object:Gem::Version
52
- version: '0.29'
53
- - - "<"
50
+ - - "~>"
54
51
  - !ruby/object:Gem::Version
55
- version: '1.0'
52
+ version: '0.42'
56
53
  type: :runtime
57
54
  prerelease: false
58
55
  version_requirements: !ruby/object:Gem::Requirement
59
56
  requirements:
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: '0.29'
63
- - - "<"
57
+ - - "~>"
64
58
  - !ruby/object:Gem::Version
65
- version: '1.0'
59
+ version: '0.42'
66
60
  description: Extracted StandardId::Providers::Apple implementation packaged as a standalone
67
61
  gem so StandardId installations can opt into Sign in with Apple independently.
68
62
  email:
@@ -79,7 +73,6 @@ files:
79
73
  - lib/generators/standard_id/apple/install/templates/initializer.rb.erb
80
74
  - lib/standard_id/apple.rb
81
75
  - lib/standard_id/apple/providers/apple.rb
82
- - lib/standard_id/apple/railtie.rb
83
76
  - lib/standard_id/apple/version.rb
84
77
  homepage: https://github.com/rarebit-one/standard_id_apple
85
78
  licenses:
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module StandardId
4
- module Apple
5
- class Railtie < ::Rails::Railtie
6
- config.after_initialize do
7
- StandardId::ProviderRegistry.register(:apple, StandardId::Providers::Apple)
8
-
9
- Rails.logger.debug("[StandardId::Apple] registered provider") if Rails.logger
10
- end
11
- end
12
- end
13
- end