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 +4 -4
- data/CHANGELOG.md +84 -0
- data/README.md +69 -4
- data/lib/generators/standard_id/apple/install/install_generator.rb +5 -2
- data/lib/generators/standard_id/apple/install/templates/initializer.rb.erb +12 -11
- data/lib/standard_id/apple/providers/apple.rb +172 -111
- data/lib/standard_id/apple/version.rb +1 -1
- data/lib/standard_id/apple.rb +6 -1
- metadata +5 -12
- data/lib/standard_id/apple/railtie.rb +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be7fc30fab9732c5874d6d9672193130821d23251e1d838bfaf952851f988a6f
|
|
4
|
+
data.tar.gz: '0529585c83478d6ce4f11a48dbc2fb54548db01ff75f46a50f3f77673b8bc234'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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["
|
|
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
|
-
|
|
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 "
|
|
68
|
+
say " APPLE_PRIVATE_KEY the .p8 private key contents, PEM, newlines intact"
|
|
69
69
|
say ""
|
|
70
|
-
say "
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
build_authorization_url(
|
|
49
|
+
endpoint: AUTH_ENDPOINT,
|
|
33
50
|
client_id: StandardId.config.apple_client_id,
|
|
34
|
-
redirect_uri:,
|
|
35
|
-
|
|
36
|
-
|
|
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, "
|
|
67
|
+
raise StandardId::InvalidRequestError, "Apple sign-in does not support the access token flow"
|
|
58
68
|
else
|
|
59
|
-
raise StandardId::InvalidRequestError, "
|
|
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:
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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,
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
raise StandardId::InvalidRequestError, "Failed to fetch Apple JWKS"
|
|
255
|
+
cache[:keys]
|
|
218
256
|
end
|
|
257
|
+
end
|
|
219
258
|
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
274
|
+
keys
|
|
275
|
+
end
|
|
276
|
+
end
|
|
228
277
|
|
|
229
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
data/lib/standard_id/apple.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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: '
|
|
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: '
|
|
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
|