standard_id 0.1.2 → 0.1.4
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/README.md +44 -1
- data/app/controllers/concerns/standard_id/api_authentication.rb +4 -0
- data/app/controllers/concerns/standard_id/social_authentication.rb +105 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +68 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +67 -72
- data/app/controllers/standard_id/web/login_controller.rb +20 -17
- data/app/controllers/standard_id/web/signup_controller.rb +1 -1
- data/app/views/standard_id/web/auth/callback/providers/apple_mobile.html.erb +50 -0
- data/app/views/standard_id/web/login/show.html.erb +3 -3
- data/app/views/standard_id/web/signup/show.html.erb +1 -1
- data/config/routes/api.rb +1 -1
- data/config/routes/web.rb +1 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +26 -0
- data/lib/standard_config/config.rb +4 -1
- data/lib/standard_id/api/authentication_guard.rb +36 -0
- data/lib/standard_id/config/schema.rb +6 -0
- data/lib/standard_id/http_client.rb +22 -0
- data/lib/standard_id/jwt_service.rb +19 -5
- data/lib/standard_id/oauth/authorization_code_flow.rb +8 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +8 -0
- data/lib/standard_id/oauth/password_flow.rb +4 -0
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/social_flow.rb +61 -0
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +9 -19
- data/lib/standard_id/oauth/token_grant_flow.rb +54 -1
- data/lib/standard_id/social_providers/apple.rb +184 -0
- data/lib/standard_id/social_providers/google.rb +168 -0
- data/lib/standard_id/social_providers/response_builder.rb +18 -0
- data/lib/standard_id/utils/callable_parameter_filter.rb +36 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +5 -0
- metadata +10 -2
- data/app/controllers/standard_id/api/providers_controller.rb +0 -174
|
@@ -23,9 +23,10 @@ module StandardConfig
|
|
|
23
23
|
# If set, Authorization endpoints can redirect to this path with a redirect_uri param
|
|
24
24
|
attr_accessor :login_url
|
|
25
25
|
|
|
26
|
-
# Social login provider credentials
|
|
26
|
+
# Social login provider credentials and hooks
|
|
27
27
|
attr_accessor :google_client_id, :google_client_secret
|
|
28
28
|
attr_accessor :apple_client_id, :apple_client_secret, :apple_private_key, :apple_key_id, :apple_team_id
|
|
29
|
+
attr_accessor :social_account_attributes, :social_callback
|
|
29
30
|
|
|
30
31
|
# Passwordless authentication callbacks
|
|
31
32
|
# These should be callable objects (procs/lambdas) that accept (recipient, code) parameters
|
|
@@ -54,6 +55,8 @@ module StandardConfig
|
|
|
54
55
|
@apple_private_key = nil
|
|
55
56
|
@apple_key_id = nil
|
|
56
57
|
@apple_team_id = nil
|
|
58
|
+
@social_account_attributes = nil
|
|
59
|
+
@social_callback = nil
|
|
57
60
|
@passwordless_email_sender = nil
|
|
58
61
|
@passwordless_sms_sender = nil
|
|
59
62
|
@allowed_post_logout_redirect_uris = []
|
|
@@ -15,6 +15,42 @@ module StandardId
|
|
|
15
15
|
|
|
16
16
|
api_session
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
def require_scopes!(session_manager, *required_scopes)
|
|
20
|
+
api_session = require_session!(session_manager)
|
|
21
|
+
|
|
22
|
+
expected_scopes = normalize_scopes(required_scopes)
|
|
23
|
+
return api_session if expected_scopes.empty?
|
|
24
|
+
|
|
25
|
+
token_scopes = extract_session_scopes(api_session)
|
|
26
|
+
unless (token_scopes & expected_scopes).any?
|
|
27
|
+
raise StandardId::InvalidScopeError,
|
|
28
|
+
"Access token missing required scope. Requires one of: #{expected_scopes.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
api_session
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def extract_session_scopes(api_session)
|
|
37
|
+
api_session&.scopes || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def normalize_scopes(required_scopes)
|
|
41
|
+
return [] if required_scopes.nil?
|
|
42
|
+
|
|
43
|
+
case required_scopes
|
|
44
|
+
when String
|
|
45
|
+
[required_scopes]
|
|
46
|
+
when Symbol
|
|
47
|
+
[required_scopes.to_s]
|
|
48
|
+
when Array
|
|
49
|
+
required_scopes.flat_map { |value| normalize_scopes(value) }.uniq
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "Scopes must be provided as a String, Symbol, or Array"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
18
54
|
end
|
|
19
55
|
end
|
|
20
56
|
end
|
|
@@ -35,6 +35,8 @@ StandardConfig.schema.draw do
|
|
|
35
35
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
36
36
|
field :client_id, type: :string, default: nil
|
|
37
37
|
field :client_secret, type: :string, default: nil
|
|
38
|
+
field :scope_claims, type: :hash, default: -> { {} }
|
|
39
|
+
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
scope :social do
|
|
@@ -45,5 +47,9 @@ StandardConfig.schema.draw do
|
|
|
45
47
|
field :apple_private_key, type: :string, default: nil
|
|
46
48
|
field :apple_key_id, type: :string, default: nil
|
|
47
49
|
field :apple_team_id, type: :string, default: nil
|
|
50
|
+
field :apple_mobile_client_id, type: :string, default: nil
|
|
51
|
+
field :social_account_attributes, type: :any, default: nil
|
|
52
|
+
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
53
|
+
field :social_callback, type: :any, default: nil
|
|
48
54
|
end
|
|
49
55
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module StandardId
|
|
5
|
+
class HttpClient
|
|
6
|
+
class << self
|
|
7
|
+
def post_form(endpoint, params)
|
|
8
|
+
uri = URI(endpoint)
|
|
9
|
+
Net::HTTP.post_form(uri, params)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get_with_bearer(endpoint, access_token)
|
|
13
|
+
uri = URI(endpoint)
|
|
14
|
+
request = Net::HTTP::Get.new(uri)
|
|
15
|
+
request["Authorization"] = "Bearer #{access_token}"
|
|
16
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
17
|
+
http.request(request)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -3,9 +3,14 @@ require "jwt"
|
|
|
3
3
|
module StandardId
|
|
4
4
|
class JwtService
|
|
5
5
|
ALGORITHM = "HS256"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
|
|
7
|
+
BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type]
|
|
8
|
+
|
|
9
|
+
def self.session_class
|
|
10
|
+
Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
|
|
11
|
+
def active?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -33,11 +38,12 @@ module StandardId
|
|
|
33
38
|
Array(payload[:scope]).compact
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
session_class.new(
|
|
42
|
+
**payload.slice(*claim_resolver_keys),
|
|
37
43
|
account_id: payload[:sub],
|
|
38
44
|
client_id: payload[:client_id],
|
|
39
45
|
scopes: scopes,
|
|
40
|
-
grant_type: payload[:grant_type]
|
|
46
|
+
grant_type: payload[:grant_type],
|
|
41
47
|
)
|
|
42
48
|
end
|
|
43
49
|
|
|
@@ -46,5 +52,13 @@ module StandardId
|
|
|
46
52
|
def self.secret_key
|
|
47
53
|
Rails.application.secret_key_base
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
def self.claim_resolver_keys
|
|
57
|
+
resolvers = StandardId.config.oauth.claim_resolvers
|
|
58
|
+
keys = Hash.try_convert(resolvers)&.keys
|
|
59
|
+
keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
49
63
|
end
|
|
50
64
|
end
|
|
@@ -48,6 +48,14 @@ module StandardId
|
|
|
48
48
|
def find_authorization_code(code)
|
|
49
49
|
StandardId::AuthorizationCode.lookup(code)
|
|
50
50
|
end
|
|
51
|
+
|
|
52
|
+
def token_client
|
|
53
|
+
@credential&.client_application
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def token_account
|
|
57
|
+
@authorization_code&.account
|
|
58
|
+
end
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oauth
|
|
3
|
+
class SocialFlow < TokenGrantFlow
|
|
4
|
+
attr_reader :account, :connection, :original_params
|
|
5
|
+
|
|
6
|
+
def initialize(params, request, account:, connection:, original_params: {})
|
|
7
|
+
super(params, request)
|
|
8
|
+
@account = account
|
|
9
|
+
@connection = connection
|
|
10
|
+
@original_params = original_params
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authenticate!
|
|
14
|
+
raise StandardId::InvalidGrantError, "Account is required for social flow" if @account.blank?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def subject_id
|
|
20
|
+
@account.id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def client_id
|
|
24
|
+
@original_params["client_id"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def token_scope
|
|
28
|
+
@original_params["scope"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def grant_type
|
|
32
|
+
"social"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def audience
|
|
36
|
+
@original_params["audience"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def supports_refresh_token?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def token_lifetime_key
|
|
44
|
+
:social
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def token_account
|
|
48
|
+
@account
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def token_client
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_jwt_payload(expires_in)
|
|
56
|
+
base_payload = super(expires_in)
|
|
57
|
+
base_payload.merge(provider: @connection).compact
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -10,7 +10,7 @@ module StandardId
|
|
|
10
10
|
|
|
11
11
|
def social_provider_url
|
|
12
12
|
@social_provider_url ||= case params[:connection]
|
|
13
|
-
when "google
|
|
13
|
+
when "google"
|
|
14
14
|
build_google_oauth_url
|
|
15
15
|
when "apple"
|
|
16
16
|
build_apple_oauth_url
|
|
@@ -20,28 +20,18 @@ module StandardId
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def build_google_oauth_url
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
StandardId::SocialProviders::Google.authorization_url(
|
|
24
|
+
state: encode_state_with_original_params,
|
|
25
25
|
redirect_uri: "#{params[:base_url]}/api/oauth/callback/google",
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
state: encode_state_with_original_params
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
"https://accounts.google.com/o/oauth2/v2/auth?" + URI.encode_www_form(google_params)
|
|
26
|
+
scope: "openid email profile"
|
|
27
|
+
)
|
|
32
28
|
end
|
|
33
29
|
|
|
34
30
|
def build_apple_oauth_url
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple"
|
|
38
|
-
|
|
39
|
-
scope: "name email",
|
|
40
|
-
response_mode: "form_post",
|
|
41
|
-
state: encode_state_with_original_params
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
"https://appleid.apple.com/auth/authorize?" + URI.encode_www_form(apple_params)
|
|
31
|
+
StandardId::SocialProviders::Apple.authorization_url(
|
|
32
|
+
state: encode_state_with_original_params,
|
|
33
|
+
redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple"
|
|
34
|
+
)
|
|
45
35
|
end
|
|
46
36
|
|
|
47
37
|
def encode_state_with_original_params
|
|
@@ -52,13 +52,15 @@ module StandardId
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def build_jwt_payload(expires_in)
|
|
55
|
-
{
|
|
55
|
+
base_payload = {
|
|
56
56
|
sub: subject_id,
|
|
57
57
|
client_id: client_id,
|
|
58
58
|
scope: token_scope,
|
|
59
59
|
grant_type: grant_type,
|
|
60
60
|
aud: audience
|
|
61
61
|
}.compact
|
|
62
|
+
|
|
63
|
+
base_payload.merge(claims_from_scope_mapping)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def token_expiry
|
|
@@ -106,6 +108,57 @@ module StandardId
|
|
|
106
108
|
def audience
|
|
107
109
|
params[:audience]
|
|
108
110
|
end
|
|
111
|
+
|
|
112
|
+
def claims_from_scope_mapping
|
|
113
|
+
scope_claims = StandardId.config.oauth.scope_claims.with_indifferent_access
|
|
114
|
+
resolvers = StandardId.config.oauth.claim_resolvers.with_indifferent_access
|
|
115
|
+
return {} if scope_claims.empty? || resolvers.empty?
|
|
116
|
+
|
|
117
|
+
claims = {}
|
|
118
|
+
current_scopes.each do |scope|
|
|
119
|
+
Array(scope_claims[scope]).each do |claim_key|
|
|
120
|
+
next if claims.key?(claim_key)
|
|
121
|
+
|
|
122
|
+
value = resolve_claim_value(resolvers[claim_key])
|
|
123
|
+
claims[claim_key] = value unless value.nil?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
claims.compact.symbolize_keys
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def current_scopes
|
|
131
|
+
Array.wrap(token_scope)
|
|
132
|
+
.flat_map { |value| value.to_s.split(/\s+/) }
|
|
133
|
+
.reject(&:blank?)
|
|
134
|
+
.uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def token_account
|
|
138
|
+
return nil if subject_id.blank?
|
|
139
|
+
|
|
140
|
+
account_class = StandardId.account_class
|
|
141
|
+
return nil unless account_class.respond_to?(:find_by)
|
|
142
|
+
|
|
143
|
+
account_class.find_by(id: subject_id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def token_client
|
|
147
|
+
StandardId::ClientApplication.find_by(client_id: client_id)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def claim_resolvers_context
|
|
151
|
+
@claim_resolvers_context ||= {
|
|
152
|
+
client: token_client,
|
|
153
|
+
account: token_account,
|
|
154
|
+
request: request
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def resolve_claim_value(resolver)
|
|
159
|
+
filtered_context = StandardId::Utils::CallableParameterFilter.filter(resolver, claim_resolvers_context)
|
|
160
|
+
resolver.call(**filtered_context)
|
|
161
|
+
end
|
|
109
162
|
end
|
|
110
163
|
end
|
|
111
164
|
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "json"
|
|
4
|
+
require "jwt"
|
|
5
|
+
require_relative "response_builder"
|
|
6
|
+
|
|
7
|
+
module StandardId
|
|
8
|
+
module SocialProviders
|
|
9
|
+
class Apple
|
|
10
|
+
include ResponseBuilder
|
|
11
|
+
|
|
12
|
+
ISSUER = "https://appleid.apple.com".freeze
|
|
13
|
+
AUTH_ENDPOINT = "#{ISSUER}/auth/authorize".freeze
|
|
14
|
+
TOKEN_ENDPOINT = "#{ISSUER}/auth/token".freeze
|
|
15
|
+
JWKS_URI = "#{ISSUER}/auth/keys".freeze
|
|
16
|
+
DEFAULT_SCOPE = "name email".freeze
|
|
17
|
+
DEFAULT_RESPONSE_MODE = "form_post".freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def authorization_url(state:, redirect_uri:, scope: DEFAULT_SCOPE, response_mode: DEFAULT_RESPONSE_MODE)
|
|
21
|
+
ensure_basic_credentials!
|
|
22
|
+
|
|
23
|
+
query = {
|
|
24
|
+
client_id: StandardId.config.apple_client_id,
|
|
25
|
+
redirect_uri: redirect_uri,
|
|
26
|
+
response_type: "code",
|
|
27
|
+
scope: scope,
|
|
28
|
+
response_mode: response_mode,
|
|
29
|
+
state: state
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
"#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_user_info(code: nil, id_token: nil, redirect_uri: nil, client_id: StandardId.config.apple_client_id)
|
|
36
|
+
if id_token.present?
|
|
37
|
+
build_response(
|
|
38
|
+
verify_id_token(id_token: id_token, client_id: client_id),
|
|
39
|
+
tokens: { id_token: id_token }
|
|
40
|
+
)
|
|
41
|
+
elsif code.present?
|
|
42
|
+
exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, client_id: client_id)
|
|
43
|
+
else
|
|
44
|
+
raise StandardId::InvalidRequestError, "Either code or id_token must be provided"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exchange_code_for_user_info(code:, redirect_uri:, client_id: StandardId.config.apple_client_id)
|
|
49
|
+
ensure_full_credentials!(client_id: client_id)
|
|
50
|
+
raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
|
|
51
|
+
|
|
52
|
+
token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
|
|
53
|
+
client_id: client_id,
|
|
54
|
+
client_secret: generate_client_secret(client_id: client_id),
|
|
55
|
+
code: code,
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
redirect_uri: redirect_uri
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
unless token_response.is_a?(Net::HTTPSuccess)
|
|
61
|
+
error_body = JSON.parse(token_response.body) rescue {}
|
|
62
|
+
raise StandardId::InvalidRequestError, "Failed to exchange Apple authorization code: #{error_body['error']}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parsed_token = JSON.parse(token_response.body)
|
|
66
|
+
id_token = parsed_token["id_token"]
|
|
67
|
+
raise StandardId::InvalidRequestError, "Apple response missing id_token" if id_token.blank?
|
|
68
|
+
|
|
69
|
+
tokens = extract_token_payload(parsed_token)
|
|
70
|
+
user_info = verify_id_token(id_token: id_token, client_id: client_id)
|
|
71
|
+
|
|
72
|
+
build_response(user_info, tokens: tokens)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
75
|
+
raise StandardId::OAuthError, e.message, cause: e
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def verify_id_token(id_token:, client_id: StandardId.config.apple_client_id)
|
|
79
|
+
raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
|
|
80
|
+
if client_id.blank?
|
|
81
|
+
raise StandardId::InvalidRequestError, "Apple client_id is not configured"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
decoded_token = JWT.decode(id_token, nil, false)
|
|
85
|
+
header = decoded_token[1]
|
|
86
|
+
|
|
87
|
+
jwk = fetch_jwk(kid: header["kid"])
|
|
88
|
+
|
|
89
|
+
verified_payload, = JWT.decode(
|
|
90
|
+
id_token,
|
|
91
|
+
jwk.public_key,
|
|
92
|
+
true,
|
|
93
|
+
algorithm: "RS256",
|
|
94
|
+
iss: ISSUER,
|
|
95
|
+
verify_iss: true,
|
|
96
|
+
aud: client_id,
|
|
97
|
+
verify_aud: true
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
"sub" => verified_payload["sub"],
|
|
102
|
+
"email" => verified_payload["email"],
|
|
103
|
+
"email_verified" => verified_payload["email_verified"],
|
|
104
|
+
"is_private_email" => verified_payload["is_private_email"]
|
|
105
|
+
}.compact
|
|
106
|
+
rescue JWT::InvalidAudError => e
|
|
107
|
+
raise StandardId::InvalidRequestError, "Invalid Apple ID token audience: #{e.message}"
|
|
108
|
+
rescue JWT::DecodeError => e
|
|
109
|
+
raise StandardId::InvalidRequestError, "Invalid Apple ID token: #{e.message}"
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
112
|
+
raise StandardId::OAuthError, e.message, cause: e
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def ensure_basic_credentials!(client_id: StandardId.config.apple_client_id)
|
|
118
|
+
if client_id.blank?
|
|
119
|
+
raise StandardId::InvalidRequestError, "Apple OAuth is not configured"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ensure_full_credentials!(client_id: nil)
|
|
124
|
+
ensure_basic_credentials!(client_id: client_id)
|
|
125
|
+
|
|
126
|
+
required = [
|
|
127
|
+
StandardId.config.apple_private_key,
|
|
128
|
+
StandardId.config.apple_key_id,
|
|
129
|
+
StandardId.config.apple_team_id
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
if required.any?(&:blank?)
|
|
133
|
+
raise StandardId::InvalidRequestError, "Apple OAuth credentials are incomplete"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def generate_client_secret(client_id: StandardId.config.apple_client_id)
|
|
138
|
+
header = {
|
|
139
|
+
alg: "ES256",
|
|
140
|
+
kid: StandardId.config.apple_key_id
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
payload = {
|
|
144
|
+
iss: StandardId.config.apple_team_id,
|
|
145
|
+
iat: Time.current.to_i,
|
|
146
|
+
exp: Time.current.to_i + 3600,
|
|
147
|
+
aud: ISSUER,
|
|
148
|
+
sub: client_id
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private_key = OpenSSL::PKey::EC.new(StandardId.config.apple_private_key)
|
|
152
|
+
JWT.encode(payload, private_key, "ES256", header)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def fetch_jwk(kid:)
|
|
156
|
+
uri = URI(JWKS_URI)
|
|
157
|
+
jwks_response = Net::HTTP.get_response(uri)
|
|
158
|
+
|
|
159
|
+
unless jwks_response.is_a?(Net::HTTPSuccess)
|
|
160
|
+
raise StandardId::InvalidRequestError, "Failed to fetch Apple JWKS"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
jwks_data = JSON.parse(jwks_response.body)
|
|
164
|
+
jwk_data = jwks_data["keys"].find { |key| key["kid"] == kid }
|
|
165
|
+
|
|
166
|
+
raise StandardId::InvalidRequestError, "JWK with kid '#{kid}' not found in Apple's JWKS" unless jwk_data
|
|
167
|
+
|
|
168
|
+
JWT::JWK.import(jwk_data)
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
171
|
+
raise StandardId::OAuthError, "Failed to fetch JWK: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def extract_token_payload(parsed_token)
|
|
175
|
+
{
|
|
176
|
+
access_token: parsed_token["access_token"],
|
|
177
|
+
refresh_token: parsed_token["refresh_token"],
|
|
178
|
+
id_token: parsed_token["id_token"]
|
|
179
|
+
}.compact
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|