standard_id-apple 0.4.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 +154 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +128 -9
- data/lib/generators/standard_id/apple/install/install_generator.rb +81 -0
- data/lib/generators/standard_id/apple/install/templates/initializer.rb.erb +43 -0
- 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 +7 -19
- data/.editorconfig +0 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -8
- data/.ruby-version +0 -1
- data/AGENTS.md +0 -72
- data/CLAUDE.md +0 -32
- data/CODE_OF_CONDUCT.md +0 -132
- 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,160 @@ 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
|
+
|
|
94
|
+
## [0.5.0] - 2026-07-31
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- **Install generator: `bin/rails g standard_id:apple:install`.** Writes
|
|
99
|
+
`config/initializers/standard_id_apple.rb` with all five `social.apple_*`
|
|
100
|
+
fields wired to ENV, then prints the environment variables the host has to
|
|
101
|
+
set (including the warning that `APPLE_PRIVATE_KEY_PEM` is multi-line and
|
|
102
|
+
that a secret store which flattens it to literal `\n` produces a key that
|
|
103
|
+
parses but fails to sign — surfacing as a JWT error at callback time, not at
|
|
104
|
+
boot). Idempotent: re-running skips an existing initializer; `--force`
|
|
105
|
+
overwrites, `--skip-initializer` writes nothing.
|
|
106
|
+
|
|
107
|
+
It writes a **separate** file rather than editing `standard_id.rb`, so the
|
|
108
|
+
provider can be removed by deleting one file and `standard_id`'s own install
|
|
109
|
+
generator stays free to overwrite its initializer without clobbering these
|
|
110
|
+
credentials. Initializers load alphabetically, so the base config is applied
|
|
111
|
+
first. The generated file uses the `config.social.` form throughout — a spec
|
|
112
|
+
pins that it never emits the unqualified `config.apple_*` form, which works
|
|
113
|
+
today only because the names happen to be unique across scopes.
|
|
114
|
+
|
|
115
|
+
Five of the nine `standard_*` gems shipped an install generator and this was
|
|
116
|
+
not one of them, which left both consumers assembling the block from the
|
|
117
|
+
README by hand.
|
|
118
|
+
|
|
119
|
+
### Documentation
|
|
120
|
+
|
|
121
|
+
- **Consumer list corrected in `CLAUDE.md`: this gem has two consumers, not
|
|
122
|
+
one.** It named `luminality-web` only; `sidekick-web` also consumes it. Both
|
|
123
|
+
live in sibling workspaces rather than beside this repo, which is how the
|
|
124
|
+
second one went unnoticed.
|
|
125
|
+
|
|
126
|
+
- **Corrected the Configuration section, which was wrong in two ways.**
|
|
127
|
+
|
|
128
|
+
It showed the *flat* form (`config.apple_client_id = ...`) rather than the
|
|
129
|
+
`social` scope the fields actually live in. The flat form works — StandardId
|
|
130
|
+
routes an unqualified name to the owning scope when it is unique across scopes
|
|
131
|
+
— but only once the field is declared, and it silently breaks the day another
|
|
132
|
+
scope declares a colliding name. Existing code using it is not broken.
|
|
133
|
+
|
|
134
|
+
It also documented a form that raised on `standard_id` <= 0.32.0. These fields
|
|
135
|
+
are declared by this gem, and until `standard_id` 0.33.0 they were declared
|
|
136
|
+
from this gem's Railtie `after_initialize` — after `config/initializers` — so
|
|
137
|
+
the plain initializer raised `StandardId::ConfigurationError: Unknown field
|
|
138
|
+
'apple_client_id' for scope 'social'`. The README now records the
|
|
139
|
+
`after_initialize` workaround for older `standard_id`, states that 0.33.0
|
|
140
|
+
declares provider fields before `:load_config_initializers` so the plain form
|
|
141
|
+
is correct there, and notes that the fields do not exist at all without this
|
|
142
|
+
gem in the Gemfile — on any `standard_id` version.
|
|
143
|
+
|
|
144
|
+
### Changed
|
|
145
|
+
|
|
146
|
+
- **`standard_id` dependency tightened from `~> 0.1, >= 0.1.7` to `~> 0.29.0`.**
|
|
147
|
+
The old constraint claimed compatibility with every `0.x` release while this
|
|
148
|
+
plugin reaches into `StandardId::ProviderRegistry` and
|
|
149
|
+
`StandardId::Providers::Apple`, and `standard_id` is pre-1.0 with breaking
|
|
150
|
+
minors. Bundler would happily resolve against an untested minor and fail at
|
|
151
|
+
runtime instead of at resolution. Both current consumers already pin
|
|
152
|
+
`standard_id "~> 0.29.0"`, so nothing existing is affected.
|
|
153
|
+
|
|
154
|
+
### Fixed
|
|
155
|
+
|
|
156
|
+
- Gemspec now uses an allow-list (`Dir["lib/**/*", …]`) rather than a
|
|
157
|
+
`git ls-files` reject-list. A reject-list fails **open** — new files ship
|
|
158
|
+
unless someone remembers to exclude them, which is how `.claude/` reached
|
|
159
|
+
published `0.3.0` of `standard_id-google` (rarebit-one/standard_id-google#69).
|
|
160
|
+
Drops `.editorconfig`, `.pinact.yaml`, `.rspec`, `.rubocop.yml`,
|
|
161
|
+
`.ruby-version`, `AGENTS.md`, `CLAUDE.md`, and `CODE_OF_CONDUCT.md` from the
|
|
162
|
+
package; `lib/` is byte-identical.
|
|
163
|
+
|
|
10
164
|
## [0.4.0] - 2026-05-19
|
|
11
165
|
|
|
12
166
|
### Changed
|
data/{LICENSE.txt → LICENSE}
RENAMED
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
|
|
@@ -20,25 +20,144 @@ bundle install
|
|
|
20
20
|
|
|
21
21
|
The gem automatically registers itself with StandardId when it is required.
|
|
22
22
|
|
|
23
|
+
Then run the install generator to drop the credentials block in place:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bin/rails g standard_id:apple:install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This writes `config/initializers/standard_id_apple.rb` — deliberately a
|
|
30
|
+
separate file from `config/initializers/standard_id.rb`, so the provider can be
|
|
31
|
+
removed by deleting one file and `standard_id`'s own install generator stays
|
|
32
|
+
free to overwrite its initializer without clobbering these values. Initializers
|
|
33
|
+
load alphabetically, so the base config is applied first. The generator is
|
|
34
|
+
idempotent; re-running on an existing initializer skips with a clear message
|
|
35
|
+
(pass `--force` to overwrite).
|
|
36
|
+
|
|
23
37
|
## Configuration
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
The generator writes this for you; the block is documented here for hosts
|
|
40
|
+
configuring by hand. Configure Apple credentials via the StandardId
|
|
41
|
+
configuration block, in the `social` scope:
|
|
26
42
|
|
|
27
43
|
```ruby
|
|
44
|
+
# config/initializers/standard_id_apple.rb
|
|
28
45
|
StandardId.configure do |config|
|
|
29
|
-
config.apple_client_id = ENV["APPLE_CLIENT_ID"]
|
|
30
|
-
config.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"] # optional
|
|
31
|
-
config.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
32
|
-
config.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
33
|
-
config.apple_private_key = ENV["
|
|
46
|
+
config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
|
|
47
|
+
config.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"] # optional
|
|
48
|
+
config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
49
|
+
config.social.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
50
|
+
config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
|
|
34
51
|
end
|
|
35
52
|
```
|
|
36
53
|
|
|
37
54
|
With those values in place, StandardId routes such as `/auth/callback/apple` continue to function using this provider gem.
|
|
38
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
|
+
|
|
110
|
+
### Two corrections to earlier versions of this section
|
|
111
|
+
|
|
112
|
+
**The `social.` prefix.** This section previously showed the flat form
|
|
113
|
+
(`config.apple_client_id = ...`). That happens to work — `StandardId`'s top-level
|
|
114
|
+
config routes an unqualified name to the owning scope when it is unique across
|
|
115
|
+
scopes, and these five are — but only *after* the field has been declared, and it
|
|
116
|
+
is the wrong thing to document: the fields live in the `social` scope, the
|
|
117
|
+
install template writes them there, and the flat form silently stops working the
|
|
118
|
+
day another scope declares a colliding name. Existing code using the flat form is
|
|
119
|
+
not broken and needs no change.
|
|
120
|
+
|
|
121
|
+
**Boot ordering.** These fields are declared by *this gem*, not by
|
|
122
|
+
`standard_id`, and until `standard_id` 0.33.0 they were declared from this gem's
|
|
123
|
+
Railtie `after_initialize` — which runs *after* `config/initializers`. On
|
|
124
|
+
`standard_id` **0.32.0 and earlier**, the block above therefore raised:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
StandardId::ConfigurationError: Unknown field 'apple_client_id' for scope 'social'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The workaround was to wrap the writes:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
# Only needed on standard_id <= 0.32.0
|
|
134
|
+
Rails.application.config.after_initialize do
|
|
135
|
+
StandardId.configure { |config| config.social.apple_client_id = ENV["APPLE_CLIENT_ID"] }
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`standard_id` **>= 0.33.0** declares every loaded provider's fields before
|
|
140
|
+
`:load_config_initializers`, so a plain initializer is correct. The wrapper is no
|
|
141
|
+
longer needed and existing ones keep working unchanged.
|
|
142
|
+
|
|
143
|
+
Note this is about *ordering*, not just versions: **the fields do not exist
|
|
144
|
+
without this gem in your Gemfile**, on any `standard_id` version. Configuring
|
|
145
|
+
`social.apple_*` with the plugin absent raises the same error, correctly.
|
|
146
|
+
|
|
39
147
|
## Testing
|
|
40
148
|
|
|
41
|
-
|
|
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:
|
|
42
161
|
|
|
43
162
|
```bash
|
|
44
163
|
bundle exec rspec
|
|
@@ -56,4 +175,4 @@ To release a new version:
|
|
|
56
175
|
|
|
57
176
|
## License
|
|
58
177
|
|
|
59
|
-
MIT — see [LICENSE
|
|
178
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module StandardId
|
|
6
|
+
module Apple
|
|
7
|
+
module Generators
|
|
8
|
+
# Installs the Apple provider's credentials block in a host Rails app.
|
|
9
|
+
#
|
|
10
|
+
# Writes `config/initializers/standard_id_apple.rb` — a separate file from
|
|
11
|
+
# `config/initializers/standard_id.rb` on purpose. The provider is opt-in
|
|
12
|
+
# per app, so its credentials should be removable by deleting one file,
|
|
13
|
+
# and `standard_id`'s own install generator must stay free to overwrite
|
|
14
|
+
# its initializer without clobbering these five values.
|
|
15
|
+
#
|
|
16
|
+
# Initializers load alphabetically, so `standard_id.rb` runs before
|
|
17
|
+
# `standard_id_apple.rb` — the base configuration is already applied by
|
|
18
|
+
# the time this file sets the `social.apple_*` fields.
|
|
19
|
+
#
|
|
20
|
+
# Idempotent: re-running skips an initializer that is already there.
|
|
21
|
+
# `--skip-initializer` opts out; `--force` overwrites.
|
|
22
|
+
class InstallGenerator < Rails::Generators::Base
|
|
23
|
+
source_root File.expand_path("templates", __dir__)
|
|
24
|
+
|
|
25
|
+
INITIALIZER_PATH = "config/initializers/standard_id_apple.rb"
|
|
26
|
+
|
|
27
|
+
desc <<~DESC
|
|
28
|
+
Installs StandardId Apple. This writes #{INITIALIZER_PATH} with the
|
|
29
|
+
five social.apple_* fields wired to ENV, and prints the env vars the
|
|
30
|
+
host needs to set.
|
|
31
|
+
|
|
32
|
+
The generator is idempotent — an existing initializer is skipped with
|
|
33
|
+
a clear message. Pass --force to overwrite.
|
|
34
|
+
DESC
|
|
35
|
+
|
|
36
|
+
class_option :skip_initializer, type: :boolean, default: false,
|
|
37
|
+
desc: "Do not write #{INITIALIZER_PATH}"
|
|
38
|
+
class_option :force, type: :boolean, default: false,
|
|
39
|
+
desc: "Overwrite #{INITIALIZER_PATH} if it already exists"
|
|
40
|
+
|
|
41
|
+
def copy_initializer
|
|
42
|
+
if options[:skip_initializer]
|
|
43
|
+
say_status("skip", "#{INITIALIZER_PATH} (--skip-initializer)", :yellow)
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if File.exist?(File.join(destination_root, INITIALIZER_PATH)) && !options[:force]
|
|
48
|
+
say_status("identical", "#{INITIALIZER_PATH} (already exists; pass --force to overwrite)", :blue)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
template "initializer.rb.erb", INITIALIZER_PATH, force: options[:force]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def print_env_hint
|
|
56
|
+
return if options[:skip_initializer]
|
|
57
|
+
|
|
58
|
+
say ""
|
|
59
|
+
say "=" * 79
|
|
60
|
+
say "StandardId Apple installed."
|
|
61
|
+
say ""
|
|
62
|
+
say "Set these in the host's environment (1Password / DO app spec / .env):"
|
|
63
|
+
say ""
|
|
64
|
+
say " APPLE_CLIENT_ID the Services ID (web sign-in)"
|
|
65
|
+
say " APPLE_MOBILE_CLIENT_ID the iOS bundle ID — optional, web-only apps skip it"
|
|
66
|
+
say " APPLE_TEAM_ID the 10-character Apple Developer team ID"
|
|
67
|
+
say " APPLE_KEY_ID the key ID of the Sign In with Apple private key"
|
|
68
|
+
say " APPLE_PRIVATE_KEY the .p8 private key contents, PEM, newlines intact"
|
|
69
|
+
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"
|
|
74
|
+
say "literal \\n — if sign-in fails on a JWT signing error, that is why."
|
|
75
|
+
say "=" * 79
|
|
76
|
+
say ""
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Apple Sign In credentials for StandardId.
|
|
4
|
+
#
|
|
5
|
+
# These five fields are declared by the standard_id-apple gem, NOT by
|
|
6
|
+
# standard_id — they do not exist unless this gem is in the Gemfile, on any
|
|
7
|
+
# standard_id version. Removing the gem and leaving this file behind raises
|
|
8
|
+
# `StandardId::ConfigurationError: Unknown field 'apple_client_id' for scope
|
|
9
|
+
# 'social'`, which is correct behaviour, not a bug.
|
|
10
|
+
#
|
|
11
|
+
# Set them in the `social` scope, as below. The unqualified form
|
|
12
|
+
# (`config.apple_client_id = ...`) happens to work today because StandardId
|
|
13
|
+
# routes a unique unqualified name to its owning scope, but it stops working
|
|
14
|
+
# the day another scope declares a colliding name. Use `config.social.`.
|
|
15
|
+
#
|
|
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).
|
|
22
|
+
#
|
|
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
|
+
StandardId.configure do |config|
|
|
28
|
+
config.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
|
|
29
|
+
|
|
30
|
+
# Optional: only set when a native iOS client signs in against the same
|
|
31
|
+
# backend. Its audience differs from the web Services ID, so leaving it nil
|
|
32
|
+
# on a web-only app is correct — don't point it at APPLE_CLIENT_ID.
|
|
33
|
+
config.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
|
|
34
|
+
|
|
35
|
+
config.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
36
|
+
config.social.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
37
|
+
|
|
38
|
+
# The .p8 contents in PEM form, newlines intact. Secret stores that flatten
|
|
39
|
+
# multi-line values to literal \n produce a key that parses but fails to
|
|
40
|
+
# sign — surfacing as a JWT error at callback time, not at boot.
|
|
41
|
+
# (Installs before standard_id-apple 0.6.0 used APPLE_PRIVATE_KEY_PEM.)
|
|
42
|
+
config.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
|
|
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,11 +1,11 @@
|
|
|
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
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -49,20 +49,14 @@ dependencies:
|
|
|
49
49
|
requirements:
|
|
50
50
|
- - "~>"
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
|
-
version: '0.
|
|
53
|
-
- - ">="
|
|
54
|
-
- !ruby/object:Gem::Version
|
|
55
|
-
version: 0.1.7
|
|
52
|
+
version: '0.42'
|
|
56
53
|
type: :runtime
|
|
57
54
|
prerelease: false
|
|
58
55
|
version_requirements: !ruby/object:Gem::Requirement
|
|
59
56
|
requirements:
|
|
60
57
|
- - "~>"
|
|
61
58
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: '0.
|
|
63
|
-
- - ">="
|
|
64
|
-
- !ruby/object:Gem::Version
|
|
65
|
-
version: 0.1.7
|
|
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:
|
|
@@ -71,20 +65,14 @@ executables: []
|
|
|
71
65
|
extensions: []
|
|
72
66
|
extra_rdoc_files: []
|
|
73
67
|
files:
|
|
74
|
-
- ".editorconfig"
|
|
75
|
-
- ".rspec"
|
|
76
|
-
- ".rubocop.yml"
|
|
77
|
-
- ".ruby-version"
|
|
78
|
-
- AGENTS.md
|
|
79
68
|
- CHANGELOG.md
|
|
80
|
-
-
|
|
81
|
-
- CODE_OF_CONDUCT.md
|
|
82
|
-
- LICENSE.txt
|
|
69
|
+
- LICENSE
|
|
83
70
|
- README.md
|
|
84
71
|
- Rakefile
|
|
72
|
+
- lib/generators/standard_id/apple/install/install_generator.rb
|
|
73
|
+
- lib/generators/standard_id/apple/install/templates/initializer.rb.erb
|
|
85
74
|
- lib/standard_id/apple.rb
|
|
86
75
|
- lib/standard_id/apple/providers/apple.rb
|
|
87
|
-
- lib/standard_id/apple/railtie.rb
|
|
88
76
|
- lib/standard_id/apple/version.rb
|
|
89
77
|
homepage: https://github.com/rarebit-one/standard_id_apple
|
|
90
78
|
licenses:
|
data/.editorconfig
DELETED
data/.rspec
DELETED
data/.rubocop.yml
DELETED
data/.ruby-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
4.0.1
|
data/AGENTS.md
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# AGENTS.md - AI Agent Guide for standard_id-apple
|
|
2
|
-
|
|
3
|
-
`standard_id-apple` is a provider plugin for the [StandardId](https://github.com/rarebit-one/standard_id) authentication engine. It packages a `StandardId::Providers::Apple` implementation for Sign in with Apple, and auto-registers itself with the host StandardId installation via a `Rails::Railtie` so apps that bundle the gem don't need an explicit initializer.
|
|
4
|
-
|
|
5
|
-
## Quick Reference
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
# Run tests
|
|
9
|
-
bundle exec rspec
|
|
10
|
-
|
|
11
|
-
# Run a single spec file
|
|
12
|
-
bundle exec rspec spec/standard_id/apple/providers/apple_spec.rb
|
|
13
|
-
|
|
14
|
-
# Run linting (note: --config flag is required on Ruby 4.0)
|
|
15
|
-
bundle exec rubocop --config .rubocop.yml
|
|
16
|
-
|
|
17
|
-
# Auto-fix lint issues
|
|
18
|
-
bundle exec rubocop --config .rubocop.yml -A
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Project Structure
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
standard_id-apple/
|
|
25
|
-
├── lib/standard_id/
|
|
26
|
-
│ ├── apple.rb # Top-level require entrypoint
|
|
27
|
-
│ └── apple/
|
|
28
|
-
│ ├── version.rb # Gem version constant
|
|
29
|
-
│ ├── railtie.rb # Auto-registers provider on after_initialize
|
|
30
|
-
│ └── providers/apple.rb # StandardId::Providers::Apple implementation
|
|
31
|
-
└── spec/
|
|
32
|
-
├── spec_helper.rb # Boots a minimal Rails app so the Railtie fires
|
|
33
|
-
└── standard_id/ # Provider specs
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Key Patterns
|
|
37
|
-
|
|
38
|
-
### Provider class
|
|
39
|
-
|
|
40
|
-
`StandardId::Providers::Apple` inherits from `StandardId::Providers::Base` (defined in the parent `standard_id` gem) and implements the provider contract: `provider_name`, `authorization_url`, `get_user_info`, `config_schema`, plus Apple-specific helpers (`verify_id_token`, `generate_client_secret`, JWKS fetching).
|
|
41
|
-
|
|
42
|
-
### Railtie auto-registration
|
|
43
|
-
|
|
44
|
-
`StandardId::Apple::Railtie` runs on `config.after_initialize` and calls `StandardId::ProviderRegistry.register(:apple, StandardId::Providers::Apple)`. Host apps just need the gem in their Gemfile — no initializer required.
|
|
45
|
-
|
|
46
|
-
### Spec bootstrapping
|
|
47
|
-
|
|
48
|
-
`spec/spec_helper.rb` defines a tiny `Rails::Application` and calls `Rails.application.initialize!` so the Railtie's `after_initialize` hook fires during the spec run; without this the provider would not appear in the registry.
|
|
49
|
-
|
|
50
|
-
## Key Files
|
|
51
|
-
|
|
52
|
-
| File | Purpose |
|
|
53
|
-
|------|---------|
|
|
54
|
-
| `lib/standard_id/apple.rb` | Top-level require entrypoint |
|
|
55
|
-
| `lib/standard_id/apple/railtie.rb` | Provider registration on Rails boot |
|
|
56
|
-
| `lib/standard_id/apple/providers/apple.rb` | Apple provider implementation |
|
|
57
|
-
| `lib/standard_id/apple/version.rb` | Gem version constant |
|
|
58
|
-
| `standard_id-apple.gemspec` | Gem metadata + runtime deps |
|
|
59
|
-
|
|
60
|
-
## Dependencies
|
|
61
|
-
|
|
62
|
-
- **standard_id** `~> 0.1`, `>= 0.1.7` (parent engine — provides `Providers::Base`, `ProviderRegistry`, errors)
|
|
63
|
-
- **activesupport** `>= 8.0` (`Time.current`, `present?`/`blank?`, indifferent access)
|
|
64
|
-
- **jwt** `~> 2.7` (id_token decoding, client_secret signing)
|
|
65
|
-
|
|
66
|
-
Dev: rspec, rubocop, webmock, lefthook.
|
|
67
|
-
|
|
68
|
-
## Testing
|
|
69
|
-
|
|
70
|
-
- WebMock stubs Apple's JWKS and token endpoints — never make real network calls in specs.
|
|
71
|
-
- The dummy Rails app in `spec_helper.rb` is intentionally minimal; add config via `StandardId.config.apple_*` setters in individual specs rather than expanding the dummy app.
|
|
72
|
-
- CI runs the full Ruby 4.0.x patch matrix via the shared `rarebit-one/.github` reusable workflow.
|
data/CLAUDE.md
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
## Worktree-Only Workflow (Enforced)
|
|
4
|
-
|
|
5
|
-
**All file modifications are blocked in the main checkout.** A PreToolUse hook (`enforce-worktree.sh`) rejects Edit, Write, and NotebookEdit operations targeting files outside a worktree. There are no opt-outs. Do not use Bash to write files in the main checkout either (e.g., `echo >`, `sed -i`, `tee`, `cp`) — the hook cannot intercept shell commands, so this rule is instruction-enforced.
|
|
6
|
-
|
|
7
|
-
Before writing any code, create a worktree:
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@refs/remotes/origin/@@')
|
|
11
|
-
DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}
|
|
12
|
-
git fetch origin "$DEFAULT_BRANCH"
|
|
13
|
-
git worktree add .worktrees/<name> -b <branch-name> "origin/$DEFAULT_BRANCH"
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
Then work inside `.worktrees/<name>/` for the rest of the session.
|
|
17
|
-
|
|
18
|
-
**Naming:** Use the Linear issue identifier if available (e.g., `.worktrees/<identifier>`), a task slug (e.g., `.worktrees/fix-auth-timeout`), or today's date (e.g., `.worktrees/2026-04-01`) as fallback.
|
|
19
|
-
|
|
20
|
-
**The hook allows modifications only when:**
|
|
21
|
-
|
|
22
|
-
1. The file is inside a git worktree (detected via `git rev-parse --git-dir` returning a path under `.git/worktrees/`)
|
|
23
|
-
2. Running in a CI/automated context where the checkout is already isolated
|
|
24
|
-
**Why this matters:** Working directly on the main checkout causes cross-contamination between sessions — uncommitted changes, wrong branches, and dirty state leak into unrelated work. Worktrees eliminate this entirely.
|
|
25
|
-
|
|
26
|
-
See the `/worktree` and `/start` skills for full conventions and flags.
|
|
27
|
-
|
|
28
|
-
## Consumers
|
|
29
|
-
|
|
30
|
-
`standard_id-apple` is currently consumed by `luminality-web` only in the rarebit-one workspace. Apple Sign In is a Luminality-only feature; `fundbright-web` and `nutripod-web` do not consume this gem.
|
|
31
|
-
|
|
32
|
-
After publishing a new version via `/publish-gem`, roll it out with the workspace-level `/rollout-gem standard_id-apple [<version>]` skill (defined at the rarebit-one workspace root, one directory above this repo). The canonical consumer matrix — including version constraints — lives in that skill's `SKILL.md`.
|
data/CODE_OF_CONDUCT.md
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
# Contributor Covenant Code of Conduct
|
|
2
|
-
|
|
3
|
-
## Our Pledge
|
|
4
|
-
|
|
5
|
-
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
-
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
-
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
-
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
-
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
-
identity and orientation.
|
|
11
|
-
|
|
12
|
-
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
-
diverse, inclusive, and healthy community.
|
|
14
|
-
|
|
15
|
-
## Our Standards
|
|
16
|
-
|
|
17
|
-
Examples of behavior that contributes to a positive environment for our
|
|
18
|
-
community include:
|
|
19
|
-
|
|
20
|
-
* Demonstrating empathy and kindness toward other people
|
|
21
|
-
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
-
* Giving and gracefully accepting constructive feedback
|
|
23
|
-
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
-
and learning from the experience
|
|
25
|
-
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
-
community
|
|
27
|
-
|
|
28
|
-
Examples of unacceptable behavior include:
|
|
29
|
-
|
|
30
|
-
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
-
any kind
|
|
32
|
-
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
-
* Public or private harassment
|
|
34
|
-
* Publishing others' private information, such as a physical or email address,
|
|
35
|
-
without their explicit permission
|
|
36
|
-
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
-
professional setting
|
|
38
|
-
|
|
39
|
-
## Enforcement Responsibilities
|
|
40
|
-
|
|
41
|
-
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
-
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
-
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
-
or harmful.
|
|
45
|
-
|
|
46
|
-
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
-
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
-
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
-
decisions when appropriate.
|
|
50
|
-
|
|
51
|
-
## Scope
|
|
52
|
-
|
|
53
|
-
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
-
an individual is officially representing the community in public spaces.
|
|
55
|
-
Examples of representing our community include using an official email address,
|
|
56
|
-
posting via an official social media account, or acting as an appointed
|
|
57
|
-
representative at an online or offline event.
|
|
58
|
-
|
|
59
|
-
## Enforcement
|
|
60
|
-
|
|
61
|
-
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
-
reported to the community leaders responsible for enforcement at
|
|
63
|
-
[INSERT CONTACT METHOD].
|
|
64
|
-
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
-
|
|
66
|
-
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
-
reporter of any incident.
|
|
68
|
-
|
|
69
|
-
## Enforcement Guidelines
|
|
70
|
-
|
|
71
|
-
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
-
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
-
|
|
74
|
-
### 1. Correction
|
|
75
|
-
|
|
76
|
-
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
-
unprofessional or unwelcome in the community.
|
|
78
|
-
|
|
79
|
-
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
-
clarity around the nature of the violation and an explanation of why the
|
|
81
|
-
behavior was inappropriate. A public apology may be requested.
|
|
82
|
-
|
|
83
|
-
### 2. Warning
|
|
84
|
-
|
|
85
|
-
**Community Impact**: A violation through a single incident or series of
|
|
86
|
-
actions.
|
|
87
|
-
|
|
88
|
-
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
-
interaction with the people involved, including unsolicited interaction with
|
|
90
|
-
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
-
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
-
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
-
ban.
|
|
94
|
-
|
|
95
|
-
### 3. Temporary Ban
|
|
96
|
-
|
|
97
|
-
**Community Impact**: A serious violation of community standards, including
|
|
98
|
-
sustained inappropriate behavior.
|
|
99
|
-
|
|
100
|
-
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
-
communication with the community for a specified period of time. No public or
|
|
102
|
-
private interaction with the people involved, including unsolicited interaction
|
|
103
|
-
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
-
Violating these terms may lead to a permanent ban.
|
|
105
|
-
|
|
106
|
-
### 4. Permanent Ban
|
|
107
|
-
|
|
108
|
-
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
-
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
-
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
-
|
|
112
|
-
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
-
community.
|
|
114
|
-
|
|
115
|
-
## Attribution
|
|
116
|
-
|
|
117
|
-
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
-
version 2.1, available at
|
|
119
|
-
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
-
|
|
121
|
-
Community Impact Guidelines were inspired by
|
|
122
|
-
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
-
|
|
124
|
-
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
-
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
-
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
-
|
|
128
|
-
[homepage]: https://www.contributor-covenant.org
|
|
129
|
-
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
-
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
-
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
-
[translations]: https://www.contributor-covenant.org/translations
|
|
@@ -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
|