standard_id 0.1.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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/stylesheets/standard_id/application.css +15 -0
  6. data/app/controllers/concerns/standard_id/api_authentication.rb +29 -0
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +51 -0
  8. data/app/controllers/standard_id/api/authorization_controller.rb +73 -0
  9. data/app/controllers/standard_id/api/base_controller.rb +61 -0
  10. data/app/controllers/standard_id/api/oauth/base_controller.rb +22 -0
  11. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +44 -0
  12. data/app/controllers/standard_id/api/oidc/logout_controller.rb +50 -0
  13. data/app/controllers/standard_id/api/passwordless_controller.rb +38 -0
  14. data/app/controllers/standard_id/api/providers_controller.rb +175 -0
  15. data/app/controllers/standard_id/api/userinfo_controller.rb +36 -0
  16. data/app/controllers/standard_id/web/account_controller.rb +32 -0
  17. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +126 -0
  18. data/app/controllers/standard_id/web/base_controller.rb +14 -0
  19. data/app/controllers/standard_id/web/login_controller.rb +69 -0
  20. data/app/controllers/standard_id/web/logout_controller.rb +20 -0
  21. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +46 -0
  22. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -0
  23. data/app/controllers/standard_id/web/sessions_controller.rb +26 -0
  24. data/app/controllers/standard_id/web/signup_controller.rb +83 -0
  25. data/app/forms/standard_id/web/reset_password_confirm_form.rb +37 -0
  26. data/app/forms/standard_id/web/reset_password_start_form.rb +38 -0
  27. data/app/forms/standard_id/web/signup_form.rb +65 -0
  28. data/app/helpers/standard_id/application_helper.rb +4 -0
  29. data/app/jobs/standard_id/application_job.rb +4 -0
  30. data/app/mailers/standard_id/application_mailer.rb +6 -0
  31. data/app/models/concerns/standard_id/account_associations.rb +14 -0
  32. data/app/models/concerns/standard_id/credentiable.rb +12 -0
  33. data/app/models/standard_id/application_record.rb +5 -0
  34. data/app/models/standard_id/authorization_code.rb +86 -0
  35. data/app/models/standard_id/browser_session.rb +27 -0
  36. data/app/models/standard_id/client_application.rb +143 -0
  37. data/app/models/standard_id/client_secret_credential.rb +63 -0
  38. data/app/models/standard_id/credential.rb +16 -0
  39. data/app/models/standard_id/device_session.rb +38 -0
  40. data/app/models/standard_id/email_identifier.rb +5 -0
  41. data/app/models/standard_id/identifier.rb +25 -0
  42. data/app/models/standard_id/password_credential.rb +24 -0
  43. data/app/models/standard_id/passwordless_challenge.rb +30 -0
  44. data/app/models/standard_id/phone_number_identifier.rb +5 -0
  45. data/app/models/standard_id/service_session.rb +44 -0
  46. data/app/models/standard_id/session.rb +54 -0
  47. data/app/models/standard_id/username_identifier.rb +5 -0
  48. data/app/views/standard_id/web/account/edit.html.erb +26 -0
  49. data/app/views/standard_id/web/account/show.html.erb +31 -0
  50. data/app/views/standard_id/web/login/show.html.erb +108 -0
  51. data/app/views/standard_id/web/reset_password/confirm/show.html.erb +27 -0
  52. data/app/views/standard_id/web/reset_password/start/show.html.erb +20 -0
  53. data/app/views/standard_id/web/sessions/index.html.erb +112 -0
  54. data/app/views/standard_id/web/signup/show.html.erb +96 -0
  55. data/config/initializers/generators.rb +9 -0
  56. data/config/initializers/migration_helpers.rb +32 -0
  57. data/config/routes/api.rb +24 -0
  58. data/config/routes/web.rb +26 -0
  59. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +56 -0
  60. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +10 -0
  61. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +17 -0
  62. data/db/migrate/20250831075703_create_standard_id_credentials.rb +10 -0
  63. data/db/migrate/20250831154635_create_standard_id_sessions.rb +43 -0
  64. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +20 -0
  65. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +46 -0
  66. data/db/migrate/20250903135906_create_standard_id_passwordless_challenges.rb +22 -0
  67. data/lib/generators/standard_id/install/install_generator.rb +14 -0
  68. data/lib/generators/standard_id/install/templates/standard_id.rb +11 -0
  69. data/lib/standard_id/api/authentication_guard.rb +20 -0
  70. data/lib/standard_id/api/session_manager.rb +39 -0
  71. data/lib/standard_id/api/token_manager.rb +50 -0
  72. data/lib/standard_id/api_engine.rb +7 -0
  73. data/lib/standard_id/config.rb +69 -0
  74. data/lib/standard_id/engine.rb +5 -0
  75. data/lib/standard_id/errors.rb +55 -0
  76. data/lib/standard_id/jwt_service.rb +50 -0
  77. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +47 -0
  78. data/lib/standard_id/oauth/authorization_code_flow.rb +53 -0
  79. data/lib/standard_id/oauth/authorization_flow.rb +91 -0
  80. data/lib/standard_id/oauth/base_request_flow.rb +43 -0
  81. data/lib/standard_id/oauth/client_credentials_flow.rb +38 -0
  82. data/lib/standard_id/oauth/implicit_authorization_flow.rb +79 -0
  83. data/lib/standard_id/oauth/password_flow.rb +70 -0
  84. data/lib/standard_id/oauth/passwordless_otp_flow.rb +87 -0
  85. data/lib/standard_id/oauth/refresh_token_flow.rb +61 -0
  86. data/lib/standard_id/oauth/subflows/base.rb +19 -0
  87. data/lib/standard_id/oauth/subflows/social_login_grant.rb +66 -0
  88. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +52 -0
  89. data/lib/standard_id/oauth/token_grant_flow.rb +107 -0
  90. data/lib/standard_id/passwordless/base_strategy.rb +67 -0
  91. data/lib/standard_id/passwordless/email_strategy.rb +27 -0
  92. data/lib/standard_id/passwordless/sms_strategy.rb +29 -0
  93. data/lib/standard_id/version.rb +3 -0
  94. data/lib/standard_id/web/authentication_guard.rb +23 -0
  95. data/lib/standard_id/web/session_manager.rb +71 -0
  96. data/lib/standard_id/web/token_manager.rb +30 -0
  97. data/lib/standard_id/web_engine.rb +7 -0
  98. data/lib/standard_id.rb +49 -0
  99. data/lib/tasks/standard_id_tasks.rake +4 -0
  100. metadata +186 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9b8808e874cdc6e89dca4b7ed1bb9636ae510d3947d48eb6f82933d40a6d9d1
4
+ data.tar.gz: 9fbd9e342e280da87695e011d5d6f031530eee53ced1c9ef93dbba4af072c141
5
+ SHA512:
6
+ metadata.gz: 1a6264b3b7f1dcf8e0f3f00861eb265d535bc8254352e6dd6c95c64dd59756db7ddf96cace31200ac0a9bb557710316a2306cdf1c52e75b5d13644530c0749e4
7
+ data.tar.gz: 5c6f5126077d8e8ca4598cf9668001b5517004ad8b444cb1fb04301979790cb2da2ed44b7f8ebeb7d75bbbd50f0ddc0fb758c40a4bbeeb9a453d975b1a66825a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) Jaryl Sim
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # StandardId
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "standard_id"
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install standard_id
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,29 @@
1
+ module StandardId
2
+ module ApiAuthentication
3
+ extend ActiveSupport::Concern
4
+
5
+ delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
6
+
7
+ private
8
+
9
+ def authenticated?
10
+ current_account.present?
11
+ end
12
+
13
+ def verify_access_token!
14
+ authentication_guard.require_session!(session_manager)
15
+ end
16
+
17
+ def session_manager
18
+ @session_manager ||= StandardId::Api::SessionManager.new(token_manager, request:)
19
+ end
20
+
21
+ def token_manager
22
+ @token_manager ||= StandardId::Api::TokenManager.new(request)
23
+ end
24
+
25
+ def authentication_guard
26
+ @authentication_guard ||= StandardId::Api::AuthenticationGuard.new
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ module StandardId
2
+ module WebAuthentication
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ helper_method :current_account, :authenticated?
7
+ end
8
+
9
+ delegate :current_session, :current_account, :revoke_current_session!, to: :session_manager
10
+
11
+ private
12
+
13
+ def authenticated?
14
+ current_account.present?
15
+ end
16
+
17
+ def require_browser_session!
18
+ authentication_guard.require_session!(session_manager, session: session, request: request)
19
+ end
20
+
21
+ def after_authentication_url
22
+ # TODO: add configurable value
23
+ session.delete(:return_to_after_authenticating) || "/"
24
+ end
25
+
26
+ def sign_in_account(login_params)
27
+ login = login_params[:email] || login_params[:login] # support both :email and :login keys
28
+ password = login_params[:password]
29
+ remember_me = ActiveModel::Type::Boolean.new.cast(login_params[:remember_me])
30
+
31
+ StandardId::PasswordCredential.find_by(login:).tap do |password_credential|
32
+ return nil unless password_credential&.authenticate(password)
33
+
34
+ session_manager.sign_in_account(password_credential.account)
35
+ session_manager.set_remember_cookie(password_credential) if remember_me
36
+ end
37
+ end
38
+
39
+ def session_manager
40
+ @session_manager ||= StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
41
+ end
42
+
43
+ def token_manager
44
+ @token_manager ||= StandardId::Web::TokenManager.new(request)
45
+ end
46
+
47
+ def authentication_guard
48
+ @authentication_guard ||= StandardId::Web::AuthenticationGuard.new
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,73 @@
1
+ module StandardId
2
+ module Api
3
+ class AuthorizationController < BaseController
4
+ include ActionController::Cookies
5
+
6
+ skip_before_action :validate_content_type!
7
+
8
+ before_action :redirect_to_login, if: :requires_authentication?
9
+
10
+ FLOW_STRATEGIES = {
11
+ "code" => StandardId::Oauth::AuthorizationCodeAuthorizationFlow,
12
+ "token" => StandardId::Oauth::ImplicitAuthorizationFlow,
13
+ "token id_token" => StandardId::Oauth::ImplicitAuthorizationFlow
14
+ }.freeze
15
+
16
+ def show
17
+ response_data = flow_strategy_class.new(flow_strategy_params, request, current_account: current_account).execute
18
+
19
+ if response_data[:redirect_to]
20
+ redirect_to response_data[:redirect_to], status: response_data[:status] || :found, allow_other_host: true
21
+ else
22
+ render json: response_data, status: :ok
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def response_type
29
+ @response_type ||= params[:response_type]
30
+ end
31
+
32
+ def flow_strategy_class
33
+ @flow_strategy_class ||= begin
34
+ if response_type.blank?
35
+ raise StandardId::InvalidRequestError, "The response_type parameter is required"
36
+ end
37
+
38
+ klass = FLOW_STRATEGIES[response_type]
39
+ unless klass
40
+ raise StandardId::UnsupportedResponseTypeError, "Unsupported response_type: #{response_type}"
41
+ end
42
+ klass
43
+ end
44
+ end
45
+
46
+ def flow_strategy_params
47
+ @flow_strategy_params ||= expect_and_permit!(flow_strategy_class.expected_params, flow_strategy_class.permitted_params)
48
+ end
49
+
50
+ def requires_authentication?
51
+ response_type&.include?("token")
52
+ end
53
+
54
+ def redirect_to_login
55
+ return if current_account.present?
56
+
57
+ base_login_url = StandardId.config.login_url.presence || "/login"
58
+ separator = base_login_url.include?("?") ? "&" : "?"
59
+ login_url = "#{base_login_url}#{separator}redirect_uri=#{CGI.escape(request.url)}"
60
+
61
+ redirect_to login_url, allow_other_host: true, status: :found
62
+ end
63
+
64
+ def current_account
65
+ @current_account ||= begin
66
+ token_manager = StandardId::Web::TokenManager.new(request)
67
+ session_manager = StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
68
+ session_manager.current_account
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,61 @@
1
+ module StandardId
2
+ module Api
3
+ class BaseController < ActionController::API
4
+ include StandardId::ApiAuthentication
5
+
6
+ before_action :validate_content_type!
7
+
8
+ after_action :set_no_store_headers
9
+
10
+ rescue_from StandardId::NotAuthenticatedError, with: :handle_not_authenticated
11
+ rescue_from StandardId::InvalidSessionError, with: :handle_invalid_session
12
+ rescue_from StandardId::OAuthError, with: :handle_oauth_error
13
+
14
+ protected
15
+
16
+ def validate_content_type!
17
+ return if request.media_type&.match?(%r{\Aapplication\/(.+\+)?json\z})
18
+ raise StandardId::InvalidRequestError, "Content-Type must be application/json or application/*+json"
19
+ end
20
+
21
+ def set_no_store_headers
22
+ response.headers["Cache-Control"] = "no-store"
23
+ response.headers["Pragma"] = "no-cache"
24
+ end
25
+
26
+ def expect_and_permit!(expected_keys, permitted_keys)
27
+ params.expect(expected_keys)
28
+ params.permit(*permitted_keys)
29
+ rescue ActionController::ParameterMissing => e
30
+ raise StandardId::InvalidRequestError, "The #{e.param} parameter is required"
31
+ end
32
+
33
+ def handle_not_authenticated(error)
34
+ render_bearer_unauthorized!(error_description: error.message.presence || default_invalid_token_message)
35
+ end
36
+
37
+ def handle_invalid_session(error)
38
+ render_bearer_unauthorized!(error_description: default_invalid_token_message)
39
+ end
40
+
41
+ def handle_oauth_error(error)
42
+ render json: {
43
+ error: error.oauth_error_code,
44
+ error_description: error.message
45
+ }, status: error.http_status
46
+ end
47
+
48
+ def render_bearer_unauthorized!(error_description: default_invalid_token_message, error_code: "invalid_token")
49
+ response.set_header(
50
+ "WWW-Authenticate",
51
+ %Q(Bearer realm="StandardId", error="#{error_code}", error_description="#{error_description}")
52
+ )
53
+ render json: { error: error_code, error_description: error_description }, status: :unauthorized
54
+ end
55
+
56
+ def default_invalid_token_message
57
+ "The access token is invalid or has expired"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ module StandardId
2
+ module Api
3
+ module Oauth
4
+ class BaseController < StandardId::Api::BaseController
5
+ rescue_from StandardId::OAuthError, with: :handle_oauth_error
6
+
7
+ private
8
+
9
+ def handle_oauth_error(exception)
10
+ error_code = exception.respond_to?(:oauth_error_code) ? exception.oauth_error_code : :invalid_request
11
+ status = exception.respond_to?(:http_status) ? exception.http_status : :bad_request
12
+ description = exception.message.presence || "An error occurred processing the request"
13
+
14
+ render json: {
15
+ error: error_code.to_s,
16
+ error_description: description
17
+ }, status: status
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ module StandardId
2
+ module Api
3
+ module Oauth
4
+ class TokensController < BaseController
5
+ FLOW_STRATEGIES = {
6
+ "client_credentials" => StandardId::Oauth::ClientCredentialsFlow,
7
+ "authorization_code" => StandardId::Oauth::AuthorizationCodeFlow,
8
+ "password" => StandardId::Oauth::PasswordFlow,
9
+ "refresh_token" => StandardId::Oauth::RefreshTokenFlow,
10
+ "passwordless_otp" => StandardId::Oauth::PasswordlessOtpFlow
11
+ }.freeze
12
+
13
+ def create
14
+ response_data = flow_strategy_class.new(flow_strategy_params, request).execute
15
+ render json: response_data, status: :ok
16
+ end
17
+
18
+ private
19
+
20
+ def grant_type
21
+ @grant_type ||= params[:grant_type]
22
+ end
23
+
24
+ def flow_strategy_class
25
+ @flow_strategy_class ||= begin
26
+ if grant_type.blank?
27
+ raise StandardId::InvalidRequestError, "The grant_type parameter is required"
28
+ end
29
+
30
+ klass = FLOW_STRATEGIES[grant_type]
31
+ unless klass
32
+ raise StandardId::UnsupportedGrantTypeError, "Unsupported grant_type: #{grant_type}"
33
+ end
34
+ klass
35
+ end
36
+ end
37
+
38
+ def flow_strategy_params
39
+ @flow_strategy_params ||= expect_and_permit!(flow_strategy_class.expected_params, flow_strategy_class.permitted_params)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ module StandardId
2
+ module Api
3
+ module Oidc
4
+ class LogoutController < ::StandardId::Api::BaseController
5
+ include ActionController::Cookies
6
+
7
+ skip_before_action :validate_content_type!
8
+
9
+ def show
10
+ session_manager.revoke_current_session!
11
+
12
+ if redirect_uri_with_state.present?
13
+ redirect_to redirect_uri_with_state, allow_other_host: true, status: :found
14
+ else
15
+ render json: { message: "You have been logged out" }, status: :ok
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def token_manager
22
+ @token_manager ||= StandardId::Web::TokenManager.new(request)
23
+ end
24
+
25
+ def session_manager
26
+ @session_manager ||= StandardId::Web::SessionManager.new(token_manager, request:, session:, cookies:)
27
+ end
28
+
29
+ def redirect_uri_with_state
30
+ return unless (uri = params[:post_logout_redirect_uri].presence)
31
+ return unless Array(StandardId.config.allowed_post_logout_redirect_uris).compact.include?(uri)
32
+
33
+ if (state = params[:state].presence)
34
+ begin
35
+ parsed = URI.parse(uri)
36
+ params = Rack::Utils.parse_nested_query(parsed.query)
37
+ params["state"] = state
38
+ parsed.query = Rack::Utils.build_query(params)
39
+ parsed.to_s
40
+ rescue URI::InvalidURIError
41
+ "#{uri}#{uri.include?('?') ? '&' : '?'}state=#{CGI.escape(state)}"
42
+ end
43
+ else
44
+ uri
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,38 @@
1
+ module StandardId
2
+ module Api
3
+ class PasswordlessController < BaseController
4
+ STRATEGY_MAP = {
5
+ "email" => StandardId::Passwordless::EmailStrategy,
6
+ "sms" => StandardId::Passwordless::SmsStrategy
7
+ }.freeze
8
+
9
+ def start
10
+ raise StandardId::InvalidRequestError, "username, email, or phone_number parameter is required" if start_params[:username].blank?
11
+
12
+ strategy_for(start_params[:connection]).start!(start_params)
13
+
14
+ render json: { message: "Code sent successfully" }, status: :ok
15
+ end
16
+
17
+ private
18
+
19
+ def strategy_for(connection)
20
+ klass = STRATEGY_MAP[connection]
21
+ raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}" unless klass
22
+ klass.new(request)
23
+ end
24
+
25
+ def start_params
26
+ return @start_params if @start_params.present?
27
+
28
+ params.expect(:connection)
29
+ permitted = params.permit(:connection, :username, :email, :phone_number)
30
+
31
+ @start_params = {
32
+ connection: permitted[:connection],
33
+ username: permitted[:username] || permitted[:email] || permitted[:phone_number]
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,175 @@
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
+ if identifier
127
+ identifier.account
128
+ else
129
+ account = Account.create!(
130
+ name: (user_info["name"] || user_info["given_name"] || email),
131
+ email: email
132
+ )
133
+
134
+ StandardId::EmailIdentifier.create!(
135
+ account: account,
136
+ value: email,
137
+ verified_at: Time.current
138
+ )
139
+
140
+ account
141
+ end
142
+ end
143
+
144
+ def generate_authorization_code
145
+ SecureRandom.urlsafe_base64(32)
146
+ end
147
+
148
+ def store_authorization_code(code, original_params, account, provider)
149
+ StandardId::AuthorizationCode.issue!(
150
+ plaintext_code: code,
151
+ client_id: original_params["client_id"],
152
+ redirect_uri: original_params["redirect_uri"],
153
+ scope: original_params["scope"],
154
+ audience: original_params["audience"],
155
+ account: account,
156
+ code_challenge: original_params["code_challenge"],
157
+ code_challenge_method: original_params["code_challenge_method"],
158
+ metadata: { state: original_params["state"], provider: provider }.compact
159
+ )
160
+ end
161
+
162
+ def build_redirect_uri(base_uri, params_hash)
163
+ uri = URI.parse(base_uri)
164
+ query_params = URI.decode_www_form(uri.query || "")
165
+
166
+ params_hash.each do |key, value|
167
+ query_params << [key.to_s, value.to_s] if value.present?
168
+ end
169
+
170
+ uri.query = URI.encode_www_form(query_params)
171
+ uri.to_s
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,36 @@
1
+ module StandardId
2
+ module Api
3
+ class UserinfoController < BaseController
4
+ skip_before_action :validate_content_type!
5
+
6
+ def show
7
+ verify_access_token!
8
+ account = current_account
9
+ raise StandardId::NotAuthenticatedError unless account
10
+
11
+ render json: build_userinfo_response(account), status: :ok
12
+ end
13
+
14
+ private
15
+
16
+ def build_userinfo_response(account)
17
+ {
18
+ sub: account_sub(account),
19
+ name: account.name,
20
+ email: account.email,
21
+ email_verified: email_verified?(account),
22
+ updated_at: account.respond_to?(:updated_at) ? account.updated_at&.to_i : nil
23
+ }.compact
24
+ end
25
+
26
+ def account_sub(account)
27
+ account.id.to_s
28
+ end
29
+
30
+ def email_verified?(account)
31
+ identifier = StandardId::EmailIdentifier.find_by(value: account.email)
32
+ identifier&.verified_at.present?
33
+ end
34
+ end
35
+ end
36
+ end