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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +44 -1
  3. data/app/controllers/concerns/standard_id/api_authentication.rb +4 -0
  4. data/app/controllers/concerns/standard_id/social_authentication.rb +105 -0
  5. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +68 -0
  6. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +67 -72
  7. data/app/controllers/standard_id/web/login_controller.rb +20 -17
  8. data/app/controllers/standard_id/web/signup_controller.rb +1 -1
  9. data/app/views/standard_id/web/auth/callback/providers/apple_mobile.html.erb +50 -0
  10. data/app/views/standard_id/web/login/show.html.erb +3 -3
  11. data/app/views/standard_id/web/signup/show.html.erb +1 -1
  12. data/config/routes/api.rb +1 -1
  13. data/config/routes/web.rb +1 -0
  14. data/lib/generators/standard_id/install/templates/standard_id.rb +26 -0
  15. data/lib/standard_config/config.rb +4 -1
  16. data/lib/standard_id/api/authentication_guard.rb +36 -0
  17. data/lib/standard_id/config/schema.rb +6 -0
  18. data/lib/standard_id/http_client.rb +22 -0
  19. data/lib/standard_id/jwt_service.rb +19 -5
  20. data/lib/standard_id/oauth/authorization_code_flow.rb +8 -0
  21. data/lib/standard_id/oauth/client_credentials_flow.rb +8 -0
  22. data/lib/standard_id/oauth/password_flow.rb +4 -0
  23. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  24. data/lib/standard_id/oauth/social_flow.rb +61 -0
  25. data/lib/standard_id/oauth/subflows/social_login_grant.rb +9 -19
  26. data/lib/standard_id/oauth/token_grant_flow.rb +54 -1
  27. data/lib/standard_id/social_providers/apple.rb +184 -0
  28. data/lib/standard_id/social_providers/google.rb +168 -0
  29. data/lib/standard_id/social_providers/response_builder.rb +18 -0
  30. data/lib/standard_id/utils/callable_parameter_filter.rb +36 -0
  31. data/lib/standard_id/version.rb +1 -1
  32. data/lib/standard_id.rb +5 -0
  33. metadata +10 -2
  34. data/app/controllers/standard_id/api/providers_controller.rb +0 -174
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
3
3
  end
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.2
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
@@ -1,174 +0,0 @@
1
- module StandardId
2
- module Api
3
- class ProvidersController < BaseController
4
- skip_before_action :validate_content_type!
5
-
6
- def google
7
- expect_and_permit!([:state, :code], [:state, :code])
8
- handle_social_callback("google-oauth2")
9
- end
10
-
11
- def apple
12
- expect_and_permit!([:state, :code], [:state, :code])
13
- handle_social_callback("apple")
14
- end
15
-
16
- private
17
-
18
- def handle_social_callback(provider)
19
- original_params = decode_state_params
20
- user_info = exchange_social_code_for_user_info(provider, params[:code])
21
- account = find_or_create_account_from_social(user_info, provider)
22
-
23
- authorization_code = generate_authorization_code
24
- store_authorization_code(authorization_code, original_params, account, provider)
25
-
26
- redirect_params = {
27
- code: authorization_code,
28
- state: original_params["state"]
29
- }.compact
30
-
31
- redirect_url = build_redirect_uri(original_params["redirect_uri"], redirect_params)
32
- redirect_to redirect_url, allow_other_host: true, status: :found
33
- end
34
-
35
- def decode_state_params
36
- encoded_state = params[:state]
37
- raise StandardId::InvalidRequestError, "Missing state parameter" if encoded_state.blank?
38
-
39
- begin
40
- JSON.parse(Base64.urlsafe_decode64(encoded_state))
41
- rescue JSON::ParserError, ArgumentError
42
- raise StandardId::InvalidRequestError, "Invalid state parameter"
43
- end
44
- end
45
-
46
- def exchange_social_code_for_user_info(provider, code)
47
- case provider
48
- when "google-oauth2"
49
- exchange_google_code(code)
50
- when "apple"
51
- exchange_apple_code(code)
52
- else
53
- raise StandardId::InvalidRequestError, "Unsupported provider: #{provider}"
54
- end
55
- end
56
-
57
- def exchange_google_code(code)
58
- token_response = HTTParty.post("https://oauth2.googleapis.com/token", {
59
- body: {
60
- client_id: StandardId.config.google_client_id,
61
- client_secret: StandardId.config.google_client_secret,
62
- code: code,
63
- grant_type: "authorization_code",
64
- redirect_uri: "#{request.base_url}/api/oauth/callback/google"
65
- },
66
- headers: { "Content-Type" => "application/x-www-form-urlencoded" }
67
- })
68
-
69
- raise StandardId::InvalidRequestError, "Failed to exchange Google code" unless token_response.success?
70
-
71
- access_token = token_response.parsed_response["access_token"]
72
-
73
- user_response = HTTParty.get("https://www.googleapis.com/oauth2/v2/userinfo", {
74
- headers: { "Authorization" => "Bearer #{access_token}" }
75
- })
76
-
77
- raise StandardId::InvalidRequestError, "Failed to get Google user info" unless user_response.success?
78
-
79
- user_response.parsed_response
80
- end
81
-
82
- def exchange_apple_code(code)
83
- client_secret = generate_apple_client_secret
84
-
85
- token_response = HTTParty.post("https://appleid.apple.com/auth/token", {
86
- body: {
87
- client_id: StandardId.config.apple_client_id,
88
- client_secret: client_secret,
89
- code: code,
90
- grant_type: "authorization_code",
91
- redirect_uri: "#{request.base_url}/api/oauth/callback/apple"
92
- },
93
- headers: { "Content-Type" => "application/x-www-form-urlencoded" }
94
- })
95
-
96
- raise StandardId::InvalidRequestError, "Failed to exchange Apple code" unless token_response.success?
97
-
98
- id_token = token_response.parsed_response["id_token"]
99
- JWT.decode(id_token, nil, false)[0]
100
- end
101
-
102
- def generate_apple_client_secret
103
- header = {
104
- alg: "ES256",
105
- kid: StandardId.config.apple_key_id
106
- }
107
-
108
- payload = {
109
- iss: StandardId.config.apple_team_id,
110
- iat: Time.current.to_i,
111
- exp: Time.current.to_i + 3600,
112
- aud: "https://appleid.apple.com",
113
- sub: StandardId.config.apple_client_id
114
- }
115
-
116
- private_key = OpenSSL::PKey::EC.new(StandardId.config.apple_private_key)
117
- JWT.encode(payload, private_key, "ES256", header)
118
- end
119
-
120
- def find_or_create_account_from_social(user_info, provider)
121
- email = user_info["email"]
122
- raise StandardId::InvalidRequestError, "No email provided by #{provider}" if email.blank?
123
-
124
- identifier = StandardId::EmailIdentifier.find_by(value: email)
125
-
126
- return identifier.account if identifier.present?
127
-
128
- account = Account.create!(
129
- name: (user_info["name"] || user_info["given_name"] || email),
130
- email:
131
- )
132
-
133
- identifier = StandardId::EmailIdentifier.create!(
134
- account:,
135
- value: email
136
- )
137
-
138
- identifier.verify!
139
-
140
- account
141
- end
142
-
143
- def generate_authorization_code
144
- SecureRandom.urlsafe_base64(32)
145
- end
146
-
147
- def store_authorization_code(code, original_params, account, provider)
148
- StandardId::AuthorizationCode.issue!(
149
- plaintext_code: code,
150
- client_id: original_params["client_id"],
151
- redirect_uri: original_params["redirect_uri"],
152
- scope: original_params["scope"],
153
- audience: original_params["audience"],
154
- account: account,
155
- code_challenge: original_params["code_challenge"],
156
- code_challenge_method: original_params["code_challenge_method"],
157
- metadata: { state: original_params["state"], provider: provider }.compact
158
- )
159
- end
160
-
161
- def build_redirect_uri(base_uri, params_hash)
162
- uri = URI.parse(base_uri)
163
- query_params = URI.decode_www_form(uri.query || "")
164
-
165
- params_hash.each do |key, value|
166
- query_params << [key.to_s, value.to_s] if value.present?
167
- end
168
-
169
- uri.query = URI.encode_www_form(query_params)
170
- uri.to_s
171
- end
172
- end
173
- end
174
- end