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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/standard_id/application.css +15 -0
- data/app/controllers/concerns/standard_id/api_authentication.rb +29 -0
- data/app/controllers/concerns/standard_id/web_authentication.rb +51 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +73 -0
- data/app/controllers/standard_id/api/base_controller.rb +61 -0
- data/app/controllers/standard_id/api/oauth/base_controller.rb +22 -0
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +44 -0
- data/app/controllers/standard_id/api/oidc/logout_controller.rb +50 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +38 -0
- data/app/controllers/standard_id/api/providers_controller.rb +175 -0
- data/app/controllers/standard_id/api/userinfo_controller.rb +36 -0
- data/app/controllers/standard_id/web/account_controller.rb +32 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +126 -0
- data/app/controllers/standard_id/web/base_controller.rb +14 -0
- data/app/controllers/standard_id/web/login_controller.rb +69 -0
- data/app/controllers/standard_id/web/logout_controller.rb +20 -0
- data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +46 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -0
- data/app/controllers/standard_id/web/sessions_controller.rb +26 -0
- data/app/controllers/standard_id/web/signup_controller.rb +83 -0
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +37 -0
- data/app/forms/standard_id/web/reset_password_start_form.rb +38 -0
- data/app/forms/standard_id/web/signup_form.rb +65 -0
- data/app/helpers/standard_id/application_helper.rb +4 -0
- data/app/jobs/standard_id/application_job.rb +4 -0
- data/app/mailers/standard_id/application_mailer.rb +6 -0
- data/app/models/concerns/standard_id/account_associations.rb +14 -0
- data/app/models/concerns/standard_id/credentiable.rb +12 -0
- data/app/models/standard_id/application_record.rb +5 -0
- data/app/models/standard_id/authorization_code.rb +86 -0
- data/app/models/standard_id/browser_session.rb +27 -0
- data/app/models/standard_id/client_application.rb +143 -0
- data/app/models/standard_id/client_secret_credential.rb +63 -0
- data/app/models/standard_id/credential.rb +16 -0
- data/app/models/standard_id/device_session.rb +38 -0
- data/app/models/standard_id/email_identifier.rb +5 -0
- data/app/models/standard_id/identifier.rb +25 -0
- data/app/models/standard_id/password_credential.rb +24 -0
- data/app/models/standard_id/passwordless_challenge.rb +30 -0
- data/app/models/standard_id/phone_number_identifier.rb +5 -0
- data/app/models/standard_id/service_session.rb +44 -0
- data/app/models/standard_id/session.rb +54 -0
- data/app/models/standard_id/username_identifier.rb +5 -0
- data/app/views/standard_id/web/account/edit.html.erb +26 -0
- data/app/views/standard_id/web/account/show.html.erb +31 -0
- data/app/views/standard_id/web/login/show.html.erb +108 -0
- data/app/views/standard_id/web/reset_password/confirm/show.html.erb +27 -0
- data/app/views/standard_id/web/reset_password/start/show.html.erb +20 -0
- data/app/views/standard_id/web/sessions/index.html.erb +112 -0
- data/app/views/standard_id/web/signup/show.html.erb +96 -0
- data/config/initializers/generators.rb +9 -0
- data/config/initializers/migration_helpers.rb +32 -0
- data/config/routes/api.rb +24 -0
- data/config/routes/web.rb +26 -0
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +56 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +10 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +17 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +10 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +43 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +20 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +46 -0
- data/db/migrate/20250903135906_create_standard_id_passwordless_challenges.rb +22 -0
- data/lib/generators/standard_id/install/install_generator.rb +14 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +11 -0
- data/lib/standard_id/api/authentication_guard.rb +20 -0
- data/lib/standard_id/api/session_manager.rb +39 -0
- data/lib/standard_id/api/token_manager.rb +50 -0
- data/lib/standard_id/api_engine.rb +7 -0
- data/lib/standard_id/config.rb +69 -0
- data/lib/standard_id/engine.rb +5 -0
- data/lib/standard_id/errors.rb +55 -0
- data/lib/standard_id/jwt_service.rb +50 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +47 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +53 -0
- data/lib/standard_id/oauth/authorization_flow.rb +91 -0
- data/lib/standard_id/oauth/base_request_flow.rb +43 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +38 -0
- data/lib/standard_id/oauth/implicit_authorization_flow.rb +79 -0
- data/lib/standard_id/oauth/password_flow.rb +70 -0
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +87 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +61 -0
- data/lib/standard_id/oauth/subflows/base.rb +19 -0
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +66 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +52 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +107 -0
- data/lib/standard_id/passwordless/base_strategy.rb +67 -0
- data/lib/standard_id/passwordless/email_strategy.rb +27 -0
- data/lib/standard_id/passwordless/sms_strategy.rb +29 -0
- data/lib/standard_id/version.rb +3 -0
- data/lib/standard_id/web/authentication_guard.rb +23 -0
- data/lib/standard_id/web/session_manager.rb +71 -0
- data/lib/standard_id/web/token_manager.rb +30 -0
- data/lib/standard_id/web_engine.rb +7 -0
- data/lib/standard_id.rb +49 -0
- data/lib/tasks/standard_id_tasks.rake +4 -0
- 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,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
|