standard_id 0.1.3 → 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 +23 -1
- 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 +17 -0
- data/lib/standard_config/config.rb +4 -1
- data/lib/standard_id/config/schema.rb +4 -0
- data/lib/standard_id/http_client.rb +22 -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 +2 -15
- 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
|
@@ -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
|
|
@@ -155,22 +155,9 @@ module StandardId
|
|
|
155
155
|
}
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
-
def callable_parameters(resolver)
|
|
159
|
-
parameters = if resolver.respond_to?(:parameters)
|
|
160
|
-
resolver.parameters
|
|
161
|
-
elsif resolver.respond_to?(:method) && resolver.respond_to?(:call)
|
|
162
|
-
resolver.method(:call).parameters
|
|
163
|
-
else
|
|
164
|
-
[]
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
accepts_all = parameters.any? { |type, _| type == :keyrest }
|
|
168
|
-
|
|
169
|
-
accepts_all ? claim_resolvers_context.keys : parameters.map { |_, name| name.to_sym }
|
|
170
|
-
end
|
|
171
|
-
|
|
172
158
|
def resolve_claim_value(resolver)
|
|
173
|
-
|
|
159
|
+
filtered_context = StandardId::Utils::CallableParameterFilter.filter(resolver, claim_resolvers_context)
|
|
160
|
+
resolver.call(**filtered_context)
|
|
174
161
|
end
|
|
175
162
|
end
|
|
176
163
|
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
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require_relative "response_builder"
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
module SocialProviders
|
|
5
|
+
class Google
|
|
6
|
+
include ResponseBuilder
|
|
7
|
+
|
|
8
|
+
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth".freeze
|
|
9
|
+
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token".freeze
|
|
10
|
+
USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo".freeze
|
|
11
|
+
TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
|
|
12
|
+
DEFAULT_SCOPE = "openid email profile".freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def authorization_url(state:, redirect_uri:, scope: DEFAULT_SCOPE, prompt: nil)
|
|
16
|
+
query = {
|
|
17
|
+
client_id: credentials[:client_id],
|
|
18
|
+
redirect_uri: redirect_uri,
|
|
19
|
+
response_type: "code",
|
|
20
|
+
scope: scope,
|
|
21
|
+
state: state
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
query[:prompt] = prompt if prompt.present?
|
|
25
|
+
|
|
26
|
+
"#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil)
|
|
30
|
+
if id_token.present?
|
|
31
|
+
build_response(
|
|
32
|
+
verify_id_token(id_token: id_token),
|
|
33
|
+
tokens: { id_token: id_token }
|
|
34
|
+
)
|
|
35
|
+
elsif access_token.present?
|
|
36
|
+
build_response(
|
|
37
|
+
fetch_user_info(access_token: access_token),
|
|
38
|
+
tokens: { access_token: access_token }
|
|
39
|
+
)
|
|
40
|
+
elsif code.present?
|
|
41
|
+
exchange_code_for_user_info(code: code, redirect_uri: redirect_uri)
|
|
42
|
+
else
|
|
43
|
+
raise StandardId::InvalidRequestError, "Either code, id_token, or access_token must be provided"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exchange_code_for_user_info(code:, redirect_uri:)
|
|
48
|
+
raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
|
|
49
|
+
|
|
50
|
+
token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
|
|
51
|
+
client_id: credentials[:client_id],
|
|
52
|
+
client_secret: credentials[:client_secret],
|
|
53
|
+
code: code,
|
|
54
|
+
grant_type: "authorization_code",
|
|
55
|
+
redirect_uri: redirect_uri
|
|
56
|
+
}.compact)
|
|
57
|
+
|
|
58
|
+
unless token_response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
raise StandardId::InvalidRequestError, "Failed to exchange Google authorization code"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
parsed_token = JSON.parse(token_response.body)
|
|
63
|
+
access_token = parsed_token["access_token"]
|
|
64
|
+
raise StandardId::InvalidRequestError, "Google response missing access token" if access_token.blank?
|
|
65
|
+
|
|
66
|
+
tokens = extract_token_payload(parsed_token)
|
|
67
|
+
user_info = fetch_user_info(access_token: access_token)
|
|
68
|
+
|
|
69
|
+
build_response(user_info, tokens: tokens)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
72
|
+
raise StandardId::OAuthError, e.message, cause: e
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def verify_id_token(id_token:)
|
|
76
|
+
raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
|
|
77
|
+
|
|
78
|
+
response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
|
|
79
|
+
|
|
80
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
81
|
+
raise StandardId::InvalidRequestError, "Invalid or expired id_token"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
token_info = JSON.parse(response.body)
|
|
85
|
+
|
|
86
|
+
unless token_info["aud"] == credentials[:client_id]
|
|
87
|
+
raise StandardId::InvalidRequestError, "ID token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info['aud']}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
unless ["accounts.google.com", "https://accounts.google.com"].include?(token_info["iss"])
|
|
91
|
+
raise StandardId::InvalidRequestError, "ID token issuer invalid. Expected Google, got: #{token_info['iss']}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
"sub" => token_info["sub"],
|
|
96
|
+
"email" => token_info["email"],
|
|
97
|
+
"email_verified" => token_info["email_verified"],
|
|
98
|
+
"name" => token_info["name"],
|
|
99
|
+
"given_name" => token_info["given_name"],
|
|
100
|
+
"family_name" => token_info["family_name"],
|
|
101
|
+
"picture" => token_info["picture"],
|
|
102
|
+
"locale" => token_info["locale"]
|
|
103
|
+
}.compact
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
106
|
+
raise StandardId::OAuthError, e.message, cause: e
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fetch_user_info(access_token:)
|
|
110
|
+
raise StandardId::InvalidRequestError, "Missing access token" if access_token.blank?
|
|
111
|
+
|
|
112
|
+
verify_token(access_token)
|
|
113
|
+
user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
|
|
114
|
+
|
|
115
|
+
unless user_response.is_a?(Net::HTTPSuccess)
|
|
116
|
+
raise StandardId::InvalidRequestError, "Failed to fetch Google user info"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
JSON.parse(user_response.body)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
raise e if e.is_a?(StandardId::OAuthError)
|
|
122
|
+
raise StandardId::OAuthError, e.message, cause: e
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def credentials
|
|
128
|
+
@credentials ||= begin
|
|
129
|
+
if StandardId.config.google_client_id.blank? || StandardId.config.google_client_secret.blank?
|
|
130
|
+
raise StandardId::InvalidRequestError, "Google provider is not configured"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
client_id: StandardId.config.google_client_id,
|
|
135
|
+
client_secret: StandardId.config.google_client_secret
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def verify_token(access_token)
|
|
141
|
+
token_info_uri = "https://www.googleapis.com/oauth2/v3/tokeninfo"
|
|
142
|
+
|
|
143
|
+
response = HttpClient.post_form(token_info_uri, access_token: access_token)
|
|
144
|
+
|
|
145
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
146
|
+
raise StandardId::InvalidRequestError, "Invalid or expired access token"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
token_info = JSON.parse(response.body)
|
|
150
|
+
|
|
151
|
+
unless token_info["aud"] == credentials[:client_id]
|
|
152
|
+
raise StandardId::InvalidRequestError, "Access token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info['aud']}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
token_info
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extract_token_payload(parsed_token)
|
|
159
|
+
{
|
|
160
|
+
access_token: parsed_token["access_token"],
|
|
161
|
+
refresh_token: parsed_token["refresh_token"],
|
|
162
|
+
id_token: parsed_token["id_token"]
|
|
163
|
+
}.compact
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module StandardId
|
|
4
|
+
module SocialProviders
|
|
5
|
+
module ResponseBuilder
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def build_response(user_info, tokens: {})
|
|
10
|
+
{
|
|
11
|
+
user_info: user_info,
|
|
12
|
+
tokens: tokens.compact
|
|
13
|
+
}.with_indifferent_access
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Utils
|
|
3
|
+
class CallableParameterFilter
|
|
4
|
+
class << self
|
|
5
|
+
def filter(callable, context)
|
|
6
|
+
return {} unless callable.respond_to?(:call) && context.present?
|
|
7
|
+
|
|
8
|
+
payload = context.to_h.symbolize_keys
|
|
9
|
+
accepted_keys = accepted_parameters(callable)
|
|
10
|
+
return payload if accepted_keys.nil?
|
|
11
|
+
|
|
12
|
+
payload.slice(*accepted_keys)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def accepted_parameters(callable)
|
|
18
|
+
parameters = parameter_list(callable)
|
|
19
|
+
return nil if parameters.any? { |type, _| type == :keyrest }
|
|
20
|
+
|
|
21
|
+
parameters.map { |_, name| name&.to_sym }.compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parameter_list(callable)
|
|
25
|
+
if callable.respond_to?(:parameters)
|
|
26
|
+
callable.parameters
|
|
27
|
+
elsif callable.respond_to?(:method)
|
|
28
|
+
callable.method(:call).parameters
|
|
29
|
+
else
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/standard_id/version.rb
CHANGED
data/lib/standard_id.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "standard_id/web_engine"
|
|
|
4
4
|
require "standard_id/api_engine"
|
|
5
5
|
require "standard_id/config/schema"
|
|
6
6
|
require "standard_id/errors"
|
|
7
|
+
require "standard_id/http_client"
|
|
7
8
|
require "standard_id/jwt_service"
|
|
8
9
|
require "standard_id/web/session_manager"
|
|
9
10
|
require "standard_id/web/token_manager"
|
|
@@ -18,6 +19,7 @@ require "standard_id/oauth/client_credentials_flow"
|
|
|
18
19
|
require "standard_id/oauth/authorization_code_flow"
|
|
19
20
|
require "standard_id/oauth/password_flow"
|
|
20
21
|
require "standard_id/oauth/refresh_token_flow"
|
|
22
|
+
require "standard_id/oauth/social_flow"
|
|
21
23
|
require "standard_id/oauth/authorization_flow"
|
|
22
24
|
require "standard_id/oauth/authorization_code_authorization_flow"
|
|
23
25
|
require "standard_id/oauth/implicit_authorization_flow"
|
|
@@ -28,6 +30,9 @@ require "standard_id/oauth/passwordless_otp_flow"
|
|
|
28
30
|
require "standard_id/passwordless/base_strategy"
|
|
29
31
|
require "standard_id/passwordless/email_strategy"
|
|
30
32
|
require "standard_id/passwordless/sms_strategy"
|
|
33
|
+
require "standard_id/utils/callable_parameter_filter"
|
|
34
|
+
require "standard_id/social_providers/google"
|
|
35
|
+
require "standard_id/social_providers/apple"
|
|
31
36
|
|
|
32
37
|
module StandardId
|
|
33
38
|
class << self
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -65,14 +65,15 @@ files:
|
|
|
65
65
|
- Rakefile
|
|
66
66
|
- app/assets/stylesheets/standard_id/application.css
|
|
67
67
|
- app/controllers/concerns/standard_id/api_authentication.rb
|
|
68
|
+
- app/controllers/concerns/standard_id/social_authentication.rb
|
|
68
69
|
- app/controllers/concerns/standard_id/web_authentication.rb
|
|
69
70
|
- app/controllers/standard_id/api/authorization_controller.rb
|
|
70
71
|
- app/controllers/standard_id/api/base_controller.rb
|
|
71
72
|
- app/controllers/standard_id/api/oauth/base_controller.rb
|
|
73
|
+
- app/controllers/standard_id/api/oauth/callback/providers_controller.rb
|
|
72
74
|
- app/controllers/standard_id/api/oauth/tokens_controller.rb
|
|
73
75
|
- app/controllers/standard_id/api/oidc/logout_controller.rb
|
|
74
76
|
- app/controllers/standard_id/api/passwordless_controller.rb
|
|
75
|
-
- app/controllers/standard_id/api/providers_controller.rb
|
|
76
77
|
- app/controllers/standard_id/api/userinfo_controller.rb
|
|
77
78
|
- app/controllers/standard_id/web/account_controller.rb
|
|
78
79
|
- app/controllers/standard_id/web/auth/callback/providers_controller.rb
|
|
@@ -114,6 +115,7 @@ files:
|
|
|
114
115
|
- app/models/standard_id/username_identifier.rb
|
|
115
116
|
- app/views/standard_id/web/account/edit.html.erb
|
|
116
117
|
- app/views/standard_id/web/account/show.html.erb
|
|
118
|
+
- app/views/standard_id/web/auth/callback/providers/apple_mobile.html.erb
|
|
117
119
|
- app/views/standard_id/web/login/show.html.erb
|
|
118
120
|
- app/views/standard_id/web/reset_password/confirm/show.html.erb
|
|
119
121
|
- app/views/standard_id/web/reset_password/start/show.html.erb
|
|
@@ -146,6 +148,7 @@ files:
|
|
|
146
148
|
- lib/standard_id/config/schema.rb
|
|
147
149
|
- lib/standard_id/engine.rb
|
|
148
150
|
- lib/standard_id/errors.rb
|
|
151
|
+
- lib/standard_id/http_client.rb
|
|
149
152
|
- lib/standard_id/jwt_service.rb
|
|
150
153
|
- lib/standard_id/oauth/authorization_code_authorization_flow.rb
|
|
151
154
|
- lib/standard_id/oauth/authorization_code_flow.rb
|
|
@@ -156,6 +159,7 @@ files:
|
|
|
156
159
|
- lib/standard_id/oauth/password_flow.rb
|
|
157
160
|
- lib/standard_id/oauth/passwordless_otp_flow.rb
|
|
158
161
|
- lib/standard_id/oauth/refresh_token_flow.rb
|
|
162
|
+
- lib/standard_id/oauth/social_flow.rb
|
|
159
163
|
- lib/standard_id/oauth/subflows/base.rb
|
|
160
164
|
- lib/standard_id/oauth/subflows/social_login_grant.rb
|
|
161
165
|
- lib/standard_id/oauth/subflows/traditional_code_grant.rb
|
|
@@ -164,6 +168,10 @@ files:
|
|
|
164
168
|
- lib/standard_id/passwordless/base_strategy.rb
|
|
165
169
|
- lib/standard_id/passwordless/email_strategy.rb
|
|
166
170
|
- lib/standard_id/passwordless/sms_strategy.rb
|
|
171
|
+
- lib/standard_id/social_providers/apple.rb
|
|
172
|
+
- lib/standard_id/social_providers/google.rb
|
|
173
|
+
- lib/standard_id/social_providers/response_builder.rb
|
|
174
|
+
- lib/standard_id/utils/callable_parameter_filter.rb
|
|
167
175
|
- lib/standard_id/version.rb
|
|
168
176
|
- lib/standard_id/web/authentication_guard.rb
|
|
169
177
|
- lib/standard_id/web/session_manager.rb
|