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
@@ -0,0 +1,79 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class ImplicitAuthorizationFlow < AuthorizationFlow
|
4
|
+
expect_params :client_id
|
5
|
+
permit_params :audience, :scope, :state, :redirect_uri, :nonce, :connection, :prompt, :organization, :invitation
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def generate_authorization_response
|
10
|
+
access_token = generate_access_token
|
11
|
+
|
12
|
+
id_token = generate_id_token if include_id_token?
|
13
|
+
|
14
|
+
fragment_params = {
|
15
|
+
access_token: access_token,
|
16
|
+
token_type: "Bearer",
|
17
|
+
expires_in: token_expiry.to_i,
|
18
|
+
scope: scope,
|
19
|
+
state: state
|
20
|
+
}
|
21
|
+
|
22
|
+
fragment_params[:id_token] = id_token if id_token
|
23
|
+
|
24
|
+
{
|
25
|
+
redirect_to: build_fragment_uri(redirect_uri, fragment_params),
|
26
|
+
status: :found
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def generate_access_token
|
31
|
+
expires_in = token_expiry
|
32
|
+
payload = build_access_token_payload(expires_in)
|
33
|
+
StandardId::JwtService.encode(payload, expires_in: expires_in)
|
34
|
+
end
|
35
|
+
|
36
|
+
def generate_id_token
|
37
|
+
return nil unless include_id_token?
|
38
|
+
|
39
|
+
expires_in = token_expiry
|
40
|
+
payload = build_id_token_payload(expires_in)
|
41
|
+
StandardId::JwtService.encode(payload, expires_in: expires_in)
|
42
|
+
end
|
43
|
+
|
44
|
+
def include_id_token?
|
45
|
+
params[:response_type]&.include?("id_token")
|
46
|
+
end
|
47
|
+
|
48
|
+
def build_access_token_payload(expires_in)
|
49
|
+
{
|
50
|
+
sub: subject_id,
|
51
|
+
client_id: params[:client_id],
|
52
|
+
scope: scope,
|
53
|
+
aud: audience,
|
54
|
+
iat: Time.current.to_i,
|
55
|
+
exp: (Time.current + expires_in).to_i
|
56
|
+
}.compact
|
57
|
+
end
|
58
|
+
|
59
|
+
def build_id_token_payload(expires_in)
|
60
|
+
{
|
61
|
+
sub: subject_id,
|
62
|
+
aud: params[:client_id],
|
63
|
+
iss: StandardId.config.issuer,
|
64
|
+
iat: Time.current.to_i,
|
65
|
+
exp: (Time.current + expires_in).to_i,
|
66
|
+
nonce: params[:nonce]
|
67
|
+
}.compact
|
68
|
+
end
|
69
|
+
|
70
|
+
def token_expiry
|
71
|
+
1.hour
|
72
|
+
end
|
73
|
+
|
74
|
+
def subject_id
|
75
|
+
current_account.id
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class PasswordFlow < TokenGrantFlow
|
4
|
+
expect_params :username, :password, :client_id
|
5
|
+
permit_params :client_secret, :audience, :scope, :realm
|
6
|
+
|
7
|
+
def authenticate!
|
8
|
+
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
9
|
+
|
10
|
+
@account = authenticate_account(params[:username], params[:password])
|
11
|
+
raise StandardId::InvalidGrantError, "Invalid username or password" if @account.blank?
|
12
|
+
|
13
|
+
validate_requested_scope!
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def subject_id
|
19
|
+
@account.id
|
20
|
+
end
|
21
|
+
|
22
|
+
def client_id
|
23
|
+
params[:client_id]
|
24
|
+
end
|
25
|
+
|
26
|
+
def token_scope
|
27
|
+
params[:scope] || default_scope
|
28
|
+
end
|
29
|
+
|
30
|
+
def grant_type
|
31
|
+
"password"
|
32
|
+
end
|
33
|
+
|
34
|
+
def audience
|
35
|
+
params[:audience]
|
36
|
+
end
|
37
|
+
|
38
|
+
def supports_refresh_token?
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
def token_expiry
|
43
|
+
8.hours # Longer expiry for user sessions
|
44
|
+
end
|
45
|
+
|
46
|
+
def authenticate_account(username, password)
|
47
|
+
StandardId::PasswordCredential
|
48
|
+
.includes(credential: :account)
|
49
|
+
.find_by(login: username)
|
50
|
+
&.authenticate(password)
|
51
|
+
&.account
|
52
|
+
end
|
53
|
+
|
54
|
+
def validate_requested_scope!
|
55
|
+
return unless params[:scope].present?
|
56
|
+
|
57
|
+
scope_tokens = params[:scope].split(/\s+/)
|
58
|
+
invalid_tokens = scope_tokens.reject { |token| token.match?(/\A[a-zA-Z0-9_:-]+\z/) }
|
59
|
+
|
60
|
+
if invalid_tokens.any?
|
61
|
+
raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(', ')}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def default_scope
|
66
|
+
"read"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class PasswordlessOtpFlow < TokenGrantFlow
|
4
|
+
expect_params :username, :otp, :connection, :client_id
|
5
|
+
permit_params :client_secret, :audience, :scope
|
6
|
+
|
7
|
+
def authenticate!
|
8
|
+
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
9
|
+
|
10
|
+
raise StandardId::InvalidGrantError, "Invalid or expired verification code" if passwordless_challenge.blank?
|
11
|
+
raise StandardId::InvalidGrantError, "Unable to authenticate user" if account.blank?
|
12
|
+
|
13
|
+
validate_requested_scope!
|
14
|
+
|
15
|
+
passwordless_challenge.use!
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def subject_id
|
21
|
+
account.id
|
22
|
+
end
|
23
|
+
|
24
|
+
def client_id
|
25
|
+
params[:client_id]
|
26
|
+
end
|
27
|
+
|
28
|
+
def token_scope
|
29
|
+
params[:scope] || default_scope
|
30
|
+
end
|
31
|
+
|
32
|
+
def grant_type
|
33
|
+
"passwordless_otp"
|
34
|
+
end
|
35
|
+
|
36
|
+
def audience
|
37
|
+
params[:audience]
|
38
|
+
end
|
39
|
+
|
40
|
+
def supports_refresh_token?
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
def token_expiry
|
45
|
+
1.hour
|
46
|
+
end
|
47
|
+
|
48
|
+
def passwordless_challenge
|
49
|
+
@passwordless_challenge ||= StandardId::PasswordlessChallenge.active.find_by(
|
50
|
+
connection_type: params[:connection],
|
51
|
+
username: params[:username],
|
52
|
+
code: params[:otp]
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
def account
|
57
|
+
@account ||= strategy_for(params[:connection]).find_or_create_account(params[:username])
|
58
|
+
end
|
59
|
+
|
60
|
+
def strategy_for(connection)
|
61
|
+
case connection
|
62
|
+
when "email"
|
63
|
+
StandardId::Passwordless::EmailStrategy.new(request)
|
64
|
+
when "sms"
|
65
|
+
StandardId::Passwordless::SmsStrategy.new(request)
|
66
|
+
else
|
67
|
+
raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def validate_requested_scope!
|
72
|
+
return unless params[:scope].present?
|
73
|
+
|
74
|
+
scope_tokens = params[:scope].split(/\s+/)
|
75
|
+
invalid_tokens = scope_tokens.reject { |token| token.match?(/\A[a-zA-Z0-9_:-]+\z/) }
|
76
|
+
|
77
|
+
if invalid_tokens.any?
|
78
|
+
raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(", ")}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def default_scope
|
83
|
+
"read"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class RefreshTokenFlow < TokenGrantFlow
|
4
|
+
expect_params :refresh_token, :client_id
|
5
|
+
permit_params :client_secret, :scope, :audience
|
6
|
+
|
7
|
+
def authenticate!
|
8
|
+
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
9
|
+
|
10
|
+
@refresh_payload = StandardId::JwtService.decode(params[:refresh_token])
|
11
|
+
raise StandardId::InvalidGrantError, "Invalid or expired refresh_token" if @refresh_payload.blank?
|
12
|
+
|
13
|
+
if @refresh_payload[:client_id] != params[:client_id]
|
14
|
+
raise StandardId::InvalidGrantError, "Refresh token was not issued to this client"
|
15
|
+
end
|
16
|
+
|
17
|
+
validate_scope_narrowing!
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def subject_id
|
23
|
+
@refresh_payload[:sub]
|
24
|
+
end
|
25
|
+
|
26
|
+
def client_id
|
27
|
+
@refresh_payload[:client_id]
|
28
|
+
end
|
29
|
+
|
30
|
+
def token_scope
|
31
|
+
requested = params[:scope].presence
|
32
|
+
return requested if requested.present?
|
33
|
+
@refresh_payload[:scope]
|
34
|
+
end
|
35
|
+
|
36
|
+
def grant_type
|
37
|
+
"refresh_token"
|
38
|
+
end
|
39
|
+
|
40
|
+
def supports_refresh_token?
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_scope_narrowing!
|
45
|
+
return unless params[:scope].present?
|
46
|
+
|
47
|
+
original_scopes = Array(@refresh_payload[:scope].to_s.split(/\s+/)).reject(&:blank?)
|
48
|
+
requested_scopes = Array(params[:scope].to_s.split(/\s+/)).reject(&:blank?)
|
49
|
+
|
50
|
+
unless (requested_scopes - original_scopes).empty?
|
51
|
+
raise StandardId::InvalidScopeError, "Requested scope exceeds originally granted scope"
|
52
|
+
end
|
53
|
+
|
54
|
+
invalid_tokens = requested_scopes.reject { |t| t.match?(/\A[a-zA-Z0-9_:-]+\z/) }
|
55
|
+
if invalid_tokens.any?
|
56
|
+
raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(', ')}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
module Subflows
|
4
|
+
class Base
|
5
|
+
def initialize(**params)
|
6
|
+
@params = params
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_reader :params
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
module Subflows
|
4
|
+
class SocialLoginGrant < Base
|
5
|
+
def call
|
6
|
+
{ redirect_to: social_provider_url, status: :found }
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def social_provider_url
|
12
|
+
@social_provider_url ||= case params[:connection]
|
13
|
+
when "google-oauth2"
|
14
|
+
build_google_oauth_url
|
15
|
+
when "apple"
|
16
|
+
build_apple_oauth_url
|
17
|
+
else
|
18
|
+
raise StandardId::InvalidRequestError, "Unsupported connection: #{params[:connection]}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def build_google_oauth_url
|
23
|
+
google_params = {
|
24
|
+
client_id: StandardId.config.google_client_id,
|
25
|
+
redirect_uri: "#{params[:base_url]}/api/oauth/callback/google",
|
26
|
+
response_type: "code",
|
27
|
+
scope: "openid email profile",
|
28
|
+
state: encode_state_with_original_params
|
29
|
+
}
|
30
|
+
|
31
|
+
"https://accounts.google.com/o/oauth2/v2/auth?" + URI.encode_www_form(google_params)
|
32
|
+
end
|
33
|
+
|
34
|
+
def build_apple_oauth_url
|
35
|
+
apple_params = {
|
36
|
+
client_id: StandardId.config.apple_client_id,
|
37
|
+
redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple",
|
38
|
+
response_type: "code",
|
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)
|
45
|
+
end
|
46
|
+
|
47
|
+
def encode_state_with_original_params
|
48
|
+
original_params = {
|
49
|
+
client_id: params[:client_id],
|
50
|
+
redirect_uri: params[:redirect_uri],
|
51
|
+
scope: params[:scope],
|
52
|
+
audience: params[:audience],
|
53
|
+
state: params[:state],
|
54
|
+
code_challenge: params[:code_challenge],
|
55
|
+
code_challenge_method: params[:code_challenge_method]
|
56
|
+
}.compact
|
57
|
+
|
58
|
+
# Remove code_challenge_method if code_challenge is not present
|
59
|
+
original_params.delete(:code_challenge_method) if original_params[:code_challenge].blank?
|
60
|
+
|
61
|
+
Base64.urlsafe_encode64(JSON.generate(original_params))
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
module Subflows
|
4
|
+
class TraditionalCodeGrant < Base
|
5
|
+
def call
|
6
|
+
store_authorization_code
|
7
|
+
|
8
|
+
redirect_params = {
|
9
|
+
code: authorization_code,
|
10
|
+
state: params[:state]
|
11
|
+
}.compact
|
12
|
+
|
13
|
+
redirect_url = build_redirect_uri(params[:redirect_uri], redirect_params)
|
14
|
+
|
15
|
+
{ redirect_to: redirect_url, status: :found }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def authorization_code
|
21
|
+
@authorization_code ||= SecureRandom.urlsafe_base64(32)
|
22
|
+
end
|
23
|
+
|
24
|
+
def store_authorization_code
|
25
|
+
StandardId::AuthorizationCode.issue!(
|
26
|
+
plaintext_code: authorization_code,
|
27
|
+
client_id: params[:client_id],
|
28
|
+
redirect_uri: params[:redirect_uri],
|
29
|
+
scope: params[:scope],
|
30
|
+
audience: params[:audience],
|
31
|
+
account: params[:current_account],
|
32
|
+
code_challenge: params[:code_challenge],
|
33
|
+
code_challenge_method: params[:code_challenge_method],
|
34
|
+
metadata: { state: params[:state] }.compact
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_redirect_uri(base_uri, params_hash)
|
39
|
+
uri = URI.parse(base_uri)
|
40
|
+
query_params = URI.decode_www_form(uri.query || "")
|
41
|
+
|
42
|
+
params_hash.each do |key, value|
|
43
|
+
query_params << [key.to_s, value.to_s] if value.present?
|
44
|
+
end
|
45
|
+
|
46
|
+
uri.query = URI.encode_www_form(query_params)
|
47
|
+
uri.to_s
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class TokenGrantFlow < BaseRequestFlow
|
4
|
+
attr_reader :params, :request
|
5
|
+
|
6
|
+
def initialize(params, request, current_account: nil)
|
7
|
+
@params = params
|
8
|
+
@request = request
|
9
|
+
@current_account = current_account
|
10
|
+
end
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def extra_permitted_keys
|
14
|
+
[:grant_type]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def execute
|
19
|
+
authenticate!
|
20
|
+
generate_token_response
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def authenticate!
|
26
|
+
raise NotImplementedError, "Subclasses must implement authenticate!"
|
27
|
+
end
|
28
|
+
|
29
|
+
def validate_client_secret!(client_id, client_secret)
|
30
|
+
client_secret_credential = StandardId::ClientSecretCredential.active.find_by(client_id: client_id)
|
31
|
+
unless client_secret_credential&.authenticate_client_secret(client_secret)
|
32
|
+
raise StandardId::InvalidClientError, "Client authentication failed"
|
33
|
+
end
|
34
|
+
client_secret_credential
|
35
|
+
end
|
36
|
+
|
37
|
+
def generate_token_response
|
38
|
+
expires_in = token_expiry
|
39
|
+
payload = build_jwt_payload(expires_in)
|
40
|
+
access_token = StandardId::JwtService.encode(payload, expires_in: expires_in)
|
41
|
+
|
42
|
+
response = {
|
43
|
+
access_token: access_token,
|
44
|
+
token_type: "Bearer",
|
45
|
+
expires_in: expires_in.to_i
|
46
|
+
}
|
47
|
+
|
48
|
+
response[:scope] = token_scope if token_scope.present?
|
49
|
+
response[:refresh_token] = generate_refresh_token if supports_refresh_token?
|
50
|
+
|
51
|
+
response.compact
|
52
|
+
end
|
53
|
+
|
54
|
+
def build_jwt_payload(expires_in)
|
55
|
+
{
|
56
|
+
sub: subject_id,
|
57
|
+
client_id: client_id,
|
58
|
+
scope: token_scope,
|
59
|
+
grant_type: grant_type,
|
60
|
+
aud: audience
|
61
|
+
}.compact
|
62
|
+
end
|
63
|
+
|
64
|
+
def token_expiry
|
65
|
+
1.hour
|
66
|
+
end
|
67
|
+
|
68
|
+
def supports_refresh_token?
|
69
|
+
false
|
70
|
+
end
|
71
|
+
|
72
|
+
def generate_refresh_token
|
73
|
+
payload = {
|
74
|
+
sub: subject_id,
|
75
|
+
client_id: client_id,
|
76
|
+
scope: token_scope,
|
77
|
+
grant_type: "refresh_token"
|
78
|
+
}
|
79
|
+
StandardId::JwtService.encode(payload, expires_in: refresh_token_expiry)
|
80
|
+
end
|
81
|
+
|
82
|
+
def refresh_token_expiry
|
83
|
+
30.days
|
84
|
+
end
|
85
|
+
|
86
|
+
def subject_id
|
87
|
+
raise NotImplementedError
|
88
|
+
end
|
89
|
+
|
90
|
+
def client_id
|
91
|
+
raise NotImplementedError
|
92
|
+
end
|
93
|
+
|
94
|
+
def token_scope
|
95
|
+
raise NotImplementedError
|
96
|
+
end
|
97
|
+
|
98
|
+
def grant_type
|
99
|
+
raise NotImplementedError
|
100
|
+
end
|
101
|
+
|
102
|
+
def audience
|
103
|
+
params[:audience]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Passwordless
|
3
|
+
class BaseStrategy
|
4
|
+
attr_reader :request
|
5
|
+
|
6
|
+
def initialize(request)
|
7
|
+
@request = request
|
8
|
+
end
|
9
|
+
|
10
|
+
def connection_type
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
# Start flow: validate recipient, create challenge, and trigger sender
|
15
|
+
# attrs: { connection:, username: }
|
16
|
+
def start!(attrs)
|
17
|
+
username = attrs[:username]
|
18
|
+
validate_username!(username)
|
19
|
+
challenge = create_challenge!(username)
|
20
|
+
sender_callback&.call(username, challenge.code)
|
21
|
+
challenge
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def create_challenge!(username)
|
27
|
+
StandardId::PasswordlessChallenge.create!(
|
28
|
+
connection_type: connection_type,
|
29
|
+
username: username,
|
30
|
+
code: generate_otp_code,
|
31
|
+
expires_at: 10.minutes.from_now,
|
32
|
+
ip_address: request.remote_ip,
|
33
|
+
user_agent: request.user_agent
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
def generate_otp_code
|
38
|
+
(SecureRandom.random_number(900_000) + 100_000).to_s
|
39
|
+
end
|
40
|
+
|
41
|
+
def validate_username!(_username)
|
42
|
+
raise NotImplementedError
|
43
|
+
end
|
44
|
+
|
45
|
+
def find_or_create_account!(_username)
|
46
|
+
raise NotImplementedError
|
47
|
+
end
|
48
|
+
|
49
|
+
public
|
50
|
+
|
51
|
+
# Public wrapper to reuse account lookup/creation outside OTP verification
|
52
|
+
def find_or_create_account(username)
|
53
|
+
validate_username!(username)
|
54
|
+
find_or_create_account!(username)
|
55
|
+
end
|
56
|
+
|
57
|
+
def identifier_class
|
58
|
+
raise NotImplementedError
|
59
|
+
end
|
60
|
+
|
61
|
+
def sender_callback
|
62
|
+
# Implement in subclasses
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Passwordless
|
3
|
+
class EmailStrategy < BaseStrategy
|
4
|
+
def connection_type
|
5
|
+
"email"
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def validate_username!(email)
|
11
|
+
raise StandardId::InvalidRequestError, "Invalid email format" unless email.to_s.match?(/\A[^@\s]+@[^@\s]+\z/)
|
12
|
+
end
|
13
|
+
|
14
|
+
def find_or_create_account!(email)
|
15
|
+
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
|
16
|
+
return identifier.account if identifier.present?
|
17
|
+
|
18
|
+
identifiers_attributes = [{ type: "StandardId::EmailIdentifier", value: email, verified_at: Time.current }]
|
19
|
+
Account.create!(identifiers_attributes:)
|
20
|
+
end
|
21
|
+
|
22
|
+
def sender_callback
|
23
|
+
StandardId.config.passwordless_email_sender
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Passwordless
|
3
|
+
class SmsStrategy < BaseStrategy
|
4
|
+
def connection_type
|
5
|
+
"sms"
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def validate_username!(phone_number)
|
11
|
+
unless phone_number.to_s.match?(/\A\+?[1-9]\d{1,14}\z/)
|
12
|
+
raise StandardId::InvalidRequestError, "Invalid phone number format"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def find_or_create_account!(phone_number)
|
17
|
+
identifier = StandardId::PhoneNumberIdentifier.includes(:account).find_by(value: phone_number)
|
18
|
+
return identifier.account if identifier.present?
|
19
|
+
|
20
|
+
identifiers_attributes = [{ type: "StandardId::PhoneNumberIdentifier", value: phone_number, verified_at: Time.current }]
|
21
|
+
Account.create!(identifiers_attributes:)
|
22
|
+
end
|
23
|
+
|
24
|
+
def sender_callback
|
25
|
+
StandardId.config.passwordless_sms_sender
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Web
|
3
|
+
class AuthenticationGuard
|
4
|
+
def require_session!(session_manager, session:, request:)
|
5
|
+
session[:return_to_after_authenticating] = request.url
|
6
|
+
|
7
|
+
browser_session = session_manager.current_session
|
8
|
+
|
9
|
+
if browser_session.blank?
|
10
|
+
raise StandardId::NotAuthenticatedError
|
11
|
+
elsif browser_session.expired?
|
12
|
+
session_manager.clear_session!
|
13
|
+
raise StandardId::ExpiredSessionError
|
14
|
+
elsif browser_session.revoked?
|
15
|
+
session_manager.clear_session!
|
16
|
+
raise StandardId::RevokedSessionError
|
17
|
+
end
|
18
|
+
|
19
|
+
browser_session
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|