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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75dbd2b3cec3ede939959396e75f19d8aea74524af5d94a19ceb3a88ac35fea5
4
- data.tar.gz: a8cbbae0a326e7289c8bd537ab61451a90c39bd93ecca72d6f6aff4c4cc34736
3
+ metadata.gz: be7fc30fab9732c5874d6d9672193130821d23251e1d838bfaf952851f988a6f
4
+ data.tar.gz: '0529585c83478d6ce4f11a48dbc2fb54548db01ff75f46a50f3f77673b8bc234'
5
5
  SHA512:
6
- metadata.gz: b34faef3f56f6b1f8e6753b99dbdf5ed11170ecd6d666f3096d3c206b09ccbde0ed9177868c737abd401b8e5025699268b2e9272e106053b87cf355932ad57c9
7
- data.tar.gz: 98dededac1c594d78d430c0c0ccb103912f997cce6369642921fd5568a078ecf9f375bea01535b6373b76e8cc9ac6cf1a3ffd0ddd92fcd78b24eac9a9892b704
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) Jaryl Sim
3
+ Copyright (c) 2026 Rarebit One
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining
6
6
  a copy of this software and associated documentation files (the
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
- Configure Apple credentials via the StandardId configuration block:
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["APPLE_PRIVATE_KEY_PEM"]
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
- 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:
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.txt](LICENSE.txt).
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
- 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.4.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,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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
8
- bindir: exe
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.1'
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.1'
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
- - CLAUDE.md
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
@@ -1,15 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- indent_style = space
5
- indent_size = 2
6
- end_of_line = lf
7
- charset = utf-8
8
- trim_trailing_whitespace = true
9
- insert_final_newline = true
10
-
11
- [*.md]
12
- trim_trailing_whitespace = false
13
-
14
- [Makefile]
15
- indent_style = tab
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- # Omakase Ruby styling for Rails
2
- inherit_gem: { rubocop-rails-omakase: rubocop.yml }
3
-
4
- # Overwrite or add rules to create your own house style
5
-
6
- # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
7
- Layout/SpaceInsideArrayLiteralBrackets:
8
- EnforcedStyle: no_space
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