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,46 @@
|
|
1
|
+
class CreateStandardIdAuthorizationCodes < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :standard_id_authorization_codes, id: primary_key_type do |t|
|
4
|
+
# Link to account when available (can be nil for pre-auth flows)
|
5
|
+
t.references :account, null: true, foreign_key: true, index: true, type: foreign_key_type
|
6
|
+
|
7
|
+
# Opaque auth code hash (SHA256 of the plaintext code), unique for lookup
|
8
|
+
t.string :code_hash, null: false
|
9
|
+
|
10
|
+
# OAuth client binding and redirect URI binding
|
11
|
+
t.string :client_id, null: false
|
12
|
+
t.text :redirect_uri, null: false
|
13
|
+
|
14
|
+
# Optional OAuth/OIDC extras
|
15
|
+
t.string :scope
|
16
|
+
t.string :audience
|
17
|
+
t.string :nonce
|
18
|
+
|
19
|
+
# PKCE
|
20
|
+
t.string :code_challenge
|
21
|
+
t.string :code_challenge_method
|
22
|
+
|
23
|
+
# Lifecycle
|
24
|
+
t.datetime :issued_at, null: false
|
25
|
+
t.datetime :expires_at, null: false
|
26
|
+
t.datetime :consumed_at
|
27
|
+
|
28
|
+
# Provider and custom metadata if needed
|
29
|
+
if connection.adapter_name.downcase.include?("postgres")
|
30
|
+
t.jsonb :metadata, default: {}, null: false
|
31
|
+
t.index :metadata, using: :gin
|
32
|
+
else
|
33
|
+
t.json :metadata, default: {}, null: false
|
34
|
+
end
|
35
|
+
|
36
|
+
t.timestamps
|
37
|
+
|
38
|
+
# Indexes
|
39
|
+
t.index :code_hash, unique: true
|
40
|
+
t.index [:client_id, :expires_at]
|
41
|
+
t.index [:account_id, :expires_at]
|
42
|
+
t.index :expires_at
|
43
|
+
t.index :consumed_at
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class CreateStandardIdPasswordlessChallenges < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :standard_id_passwordless_challenges do |t|
|
4
|
+
t.string :connection_type, null: false
|
5
|
+
|
6
|
+
t.string :username, null: false
|
7
|
+
t.string :code, null: false
|
8
|
+
|
9
|
+
t.datetime :expires_at, null: false
|
10
|
+
t.datetime :used_at
|
11
|
+
|
12
|
+
t.string :ip_address
|
13
|
+
t.text :user_agent
|
14
|
+
|
15
|
+
t.timestamps
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :standard_id_passwordless_challenges, [:connection_type, :username, :code], name: "index_passwordless_challenges_on_lookup"
|
19
|
+
add_index :standard_id_passwordless_challenges, :expires_at
|
20
|
+
add_index :standard_id_passwordless_challenges, :used_at
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
|
3
|
+
module StandardId
|
4
|
+
module Generators
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
desc "Creates a StandardId initializer at config/initializers/standard_id.rb"
|
8
|
+
|
9
|
+
def create_initializer_file
|
10
|
+
template "standard_id.rb", "config/initializers/standard_id.rb"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# StandardId configuration
|
2
|
+
# Generated by: rails g standard_id:install
|
3
|
+
|
4
|
+
StandardId.configure do |c|
|
5
|
+
# Set to the String name of your account model (e.g., "User" or "Account")
|
6
|
+
c.account_class_name = "User"
|
7
|
+
|
8
|
+
# Optional: customize cache store and logger used internally by StandardId
|
9
|
+
# c.cache_store = Rails.cache
|
10
|
+
# c.logger = Rails.logger
|
11
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Api
|
3
|
+
class AuthenticationGuard
|
4
|
+
def require_session!(session_manager)
|
5
|
+
api_session = session_manager.current_session
|
6
|
+
|
7
|
+
if api_session.blank?
|
8
|
+
raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
|
9
|
+
elsif api_session.respond_to?(:expired?) && api_session.expired?
|
10
|
+
raise StandardId::ExpiredSessionError, "Session has expired"
|
11
|
+
elsif api_session.respond_to?(:revoked?) && api_session.revoked?
|
12
|
+
session_manager.clear_session!
|
13
|
+
raise StandardId::RevokedSessionError, "Session has been revoked"
|
14
|
+
end
|
15
|
+
|
16
|
+
api_session
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Api
|
3
|
+
class SessionManager
|
4
|
+
def initialize(token_manager, request:)
|
5
|
+
@token_manager = token_manager
|
6
|
+
@request = request
|
7
|
+
end
|
8
|
+
|
9
|
+
def current_session
|
10
|
+
@current_session ||= load_current_session
|
11
|
+
end
|
12
|
+
|
13
|
+
def current_account
|
14
|
+
return unless current_session
|
15
|
+
@current_account ||= StandardId.config.account_class.find_by(id: current_session.account_id)
|
16
|
+
end
|
17
|
+
|
18
|
+
def revoke_current_session!
|
19
|
+
clear_session!
|
20
|
+
end
|
21
|
+
|
22
|
+
def clear_session!
|
23
|
+
@current_session = nil
|
24
|
+
@current_account = nil
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def load_current_session
|
30
|
+
return @current_session if @current_session.present?
|
31
|
+
|
32
|
+
jwt_session = @token_manager.verify_jwt_token
|
33
|
+
return unless jwt_session&.active?
|
34
|
+
|
35
|
+
@current_session = jwt_session
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Api
|
3
|
+
class TokenManager
|
4
|
+
attr_reader :request
|
5
|
+
|
6
|
+
def initialize(request)
|
7
|
+
@request = request
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_device_session(account, device_id: nil, device_agent: nil)
|
11
|
+
StandardId::DeviceSession.create!(
|
12
|
+
account:,
|
13
|
+
ip_address: @request.remote_ip,
|
14
|
+
device_id: device_id || SecureRandom.uuid,
|
15
|
+
device_agent: device_agent || @request.user_agent,
|
16
|
+
expires_at: 30.days.from_now # TODO: make this configurable
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def create_service_session(account, service_name:, service_version:, owner:, metadata: {})
|
21
|
+
StandardId::ServiceSession.create!(
|
22
|
+
account:,
|
23
|
+
owner:,
|
24
|
+
ip_address: @request.remote_ip,
|
25
|
+
service_name:,
|
26
|
+
service_version:,
|
27
|
+
metadata: metadata || {},
|
28
|
+
expires_at: StandardId::ServiceSession.default_expiry
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def bearer_token
|
33
|
+
return @bearer_token if @bearer_token.present?
|
34
|
+
|
35
|
+
auth_header = @request.headers["Authorization"]
|
36
|
+
return unless auth_header&.start_with?("Bearer ")
|
37
|
+
|
38
|
+
@bearer_token = auth_header.split(" ", 2).last
|
39
|
+
end
|
40
|
+
|
41
|
+
def verify_jwt_token(token: bearer_token)
|
42
|
+
StandardId::JwtService.decode_session(token)
|
43
|
+
end
|
44
|
+
|
45
|
+
def generate_lookup_hash(token)
|
46
|
+
Digest::SHA256.hexdigest("#{token}:#{Rails.application.secret_key_base}")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module StandardId
|
2
|
+
# Manages configuration for the StandardId engine
|
3
|
+
#
|
4
|
+
# Usage:
|
5
|
+
# StandardId.configure do |config|
|
6
|
+
# config.account_class_name = "User"
|
7
|
+
# config.cache_store = ActiveSupport::Cache::MemoryStore.new
|
8
|
+
# config.logger = Rails.logger
|
9
|
+
# config.allowed_post_logout_redirect_uris = ["https://example.com/logout"]
|
10
|
+
# end
|
11
|
+
class Config
|
12
|
+
# The name of the Account model class as a String, e.g. "User" or "Account"
|
13
|
+
attr_accessor :account_class_name
|
14
|
+
|
15
|
+
# Optional cache store and logger, used by StandardId.cache_store and StandardId.logger
|
16
|
+
attr_accessor :cache_store, :logger
|
17
|
+
|
18
|
+
# OAuth issuer identifier for ID tokens
|
19
|
+
attr_accessor :issuer
|
20
|
+
|
21
|
+
# Optional login URL for redirecting unauthenticated browser requests
|
22
|
+
# Example: "/login" or a full URL like "https://app.example.com/login"
|
23
|
+
# If set, Authorization endpoints can redirect to this path with a redirect_uri param
|
24
|
+
attr_accessor :login_url
|
25
|
+
|
26
|
+
# Social login provider credentials
|
27
|
+
attr_accessor :google_client_id, :google_client_secret
|
28
|
+
attr_accessor :apple_client_id, :apple_client_secret, :apple_private_key, :apple_key_id, :apple_team_id
|
29
|
+
|
30
|
+
# Passwordless authentication callbacks
|
31
|
+
# These should be callable objects (procs/lambdas) that accept (recipient, code) parameters
|
32
|
+
attr_accessor :passwordless_email_sender, :passwordless_sms_sender
|
33
|
+
|
34
|
+
# Allowed post-logout redirect URIs for OIDC logout endpoint
|
35
|
+
# If empty or nil, no redirects are allowed and the endpoint will return a JSON message
|
36
|
+
# If provided, the post_logout_redirect_uri must exactly match one of the values in this list
|
37
|
+
attr_accessor :allowed_post_logout_redirect_uris
|
38
|
+
|
39
|
+
# Layout name to use for StandardId Web controllers.
|
40
|
+
# If nil, controllers should default to "application" (host app or dummy app).
|
41
|
+
# Examples: "application", "standard_id/web/application", "my_custom_layout"
|
42
|
+
attr_accessor :web_layout
|
43
|
+
|
44
|
+
def initialize
|
45
|
+
@account_class_name = nil
|
46
|
+
@cache_store = nil
|
47
|
+
@logger = nil
|
48
|
+
@issuer = nil
|
49
|
+
@login_url = nil
|
50
|
+
@google_client_id = nil
|
51
|
+
@google_client_secret = nil
|
52
|
+
@apple_client_id = nil
|
53
|
+
@apple_client_secret = nil
|
54
|
+
@apple_private_key = nil
|
55
|
+
@apple_key_id = nil
|
56
|
+
@apple_team_id = nil
|
57
|
+
@passwordless_email_sender = nil
|
58
|
+
@passwordless_sms_sender = nil
|
59
|
+
@allowed_post_logout_redirect_uris = []
|
60
|
+
@web_layout = nil
|
61
|
+
end
|
62
|
+
|
63
|
+
def account_class
|
64
|
+
account_class_name.constantize
|
65
|
+
rescue NameError
|
66
|
+
raise NameError, "Could not find account class: #{account_class_name}. Please set a valid class name using `StandardId.configure { |c| c.account_class_name = 'YourAccountClass' }`"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module StandardId
|
2
|
+
class NotAuthenticatedError < StandardError; end
|
3
|
+
|
4
|
+
class InvalidSessionError < StandardError; end
|
5
|
+
class ExpiredSessionError < InvalidSessionError; end
|
6
|
+
class RevokedSessionError < InvalidSessionError; end
|
7
|
+
|
8
|
+
class OAuthError < StandardError
|
9
|
+
def oauth_error_code
|
10
|
+
:invalid_request
|
11
|
+
end
|
12
|
+
|
13
|
+
def http_status
|
14
|
+
:bad_request
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class UnsupportedGrantTypeError < OAuthError
|
19
|
+
def oauth_error_code = :unsupported_grant_type
|
20
|
+
end
|
21
|
+
|
22
|
+
class MissingClientSecretCredentialsError < OAuthError
|
23
|
+
def oauth_error_code = :invalid_request
|
24
|
+
end
|
25
|
+
|
26
|
+
class InvalidClientSecretCredentialsError < OAuthError
|
27
|
+
def oauth_error_code = :invalid_client
|
28
|
+
def http_status = :unauthorized
|
29
|
+
end
|
30
|
+
|
31
|
+
class InvalidRequestError < OAuthError
|
32
|
+
def oauth_error_code = :invalid_request
|
33
|
+
end
|
34
|
+
|
35
|
+
class InvalidClientError < OAuthError
|
36
|
+
def oauth_error_code = :invalid_client
|
37
|
+
def http_status = :unauthorized
|
38
|
+
end
|
39
|
+
|
40
|
+
class InvalidGrantError < OAuthError
|
41
|
+
def oauth_error_code = :invalid_grant
|
42
|
+
end
|
43
|
+
|
44
|
+
class InvalidScopeError < OAuthError
|
45
|
+
def oauth_error_code = :invalid_scope
|
46
|
+
end
|
47
|
+
|
48
|
+
class UnauthorizedClientError < OAuthError
|
49
|
+
def oauth_error_code = :unauthorized_client
|
50
|
+
end
|
51
|
+
|
52
|
+
class UnsupportedResponseTypeError < OAuthError
|
53
|
+
def oauth_error_code = :unsupported_response_type
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "jwt"
|
2
|
+
|
3
|
+
module StandardId
|
4
|
+
class JwtService
|
5
|
+
ALGORITHM = "HS256"
|
6
|
+
Session = Struct.new(:account_id, :client_id, :scopes, :grant_type, keyword_init: true) do
|
7
|
+
def active?
|
8
|
+
true
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.encode(payload, expires_in: 1.hour)
|
13
|
+
payload[:exp] = expires_in.from_now.to_i
|
14
|
+
payload[:iat] = Time.current.to_i
|
15
|
+
|
16
|
+
JWT.encode(payload, secret_key, ALGORITHM)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.decode(token)
|
20
|
+
decoded = JWT.decode(token, secret_key, true, { algorithm: ALGORITHM })
|
21
|
+
decoded.first.with_indifferent_access
|
22
|
+
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.decode_session(token)
|
27
|
+
payload = decode(token)
|
28
|
+
return unless payload
|
29
|
+
|
30
|
+
scopes = if payload[:scope].is_a?(String)
|
31
|
+
payload[:scope].split(" ")
|
32
|
+
else
|
33
|
+
Array(payload[:scope]).compact
|
34
|
+
end
|
35
|
+
|
36
|
+
Session.new(
|
37
|
+
account_id: payload[:sub],
|
38
|
+
client_id: payload[:client_id],
|
39
|
+
scopes: scopes,
|
40
|
+
grant_type: payload[:grant_type]
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def self.secret_key
|
47
|
+
Rails.application.secret_key_base
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class AuthorizationCodeAuthorizationFlow < AuthorizationFlow
|
4
|
+
expect_params :client_id, :audience
|
5
|
+
permit_params :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def generate_authorization_response
|
10
|
+
subflow_for(params).call
|
11
|
+
end
|
12
|
+
|
13
|
+
def subflow_for(flow_params)
|
14
|
+
builders = {
|
15
|
+
social: -> do
|
16
|
+
Subflows::SocialLoginGrant.new(
|
17
|
+
**common_subflow_params(flow_params),
|
18
|
+
connection: flow_params[:connection],
|
19
|
+
base_url: request.base_url
|
20
|
+
)
|
21
|
+
end,
|
22
|
+
traditional: -> do
|
23
|
+
Subflows::TraditionalCodeGrant.new(
|
24
|
+
**common_subflow_params(flow_params),
|
25
|
+
current_account: current_account
|
26
|
+
)
|
27
|
+
end
|
28
|
+
}
|
29
|
+
|
30
|
+
key = flow_params[:connection].present? ? :social : :traditional
|
31
|
+
builders.fetch(key).call
|
32
|
+
end
|
33
|
+
|
34
|
+
def common_subflow_params(flow_params)
|
35
|
+
{
|
36
|
+
client_id: flow_params[:client_id],
|
37
|
+
redirect_uri: redirect_uri,
|
38
|
+
scope: scope,
|
39
|
+
audience: audience,
|
40
|
+
state: state,
|
41
|
+
code_challenge: flow_params[:code_challenge],
|
42
|
+
code_challenge_method: flow_params[:code_challenge_method]
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class AuthorizationCodeFlow < TokenGrantFlow
|
4
|
+
expect_params :client_id, :client_secret, :code
|
5
|
+
permit_params :redirect_uri, :code_verifier
|
6
|
+
|
7
|
+
def authenticate!
|
8
|
+
@credential = validate_client_secret!(params[:client_id], params[:client_secret])
|
9
|
+
|
10
|
+
@authorization_code = find_authorization_code(params[:code])
|
11
|
+
unless @authorization_code&.valid_for_client?(params[:client_id])
|
12
|
+
raise StandardId::InvalidGrantError, "Invalid or expired authorization code"
|
13
|
+
end
|
14
|
+
|
15
|
+
if params[:redirect_uri].present? && @authorization_code.redirect_uri != params[:redirect_uri]
|
16
|
+
raise StandardId::InvalidGrantError, "Redirect URI mismatch"
|
17
|
+
end
|
18
|
+
|
19
|
+
unless @authorization_code.pkce_valid?(params[:code_verifier])
|
20
|
+
raise StandardId::InvalidGrantError, "Invalid PKCE code_verifier"
|
21
|
+
end
|
22
|
+
|
23
|
+
@authorization_code.mark_as_used!
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def subject_id
|
29
|
+
@authorization_code.account_id
|
30
|
+
end
|
31
|
+
|
32
|
+
def client_id
|
33
|
+
@credential.client_id
|
34
|
+
end
|
35
|
+
|
36
|
+
def token_scope
|
37
|
+
@authorization_code.scope
|
38
|
+
end
|
39
|
+
|
40
|
+
def grant_type
|
41
|
+
"authorization_code"
|
42
|
+
end
|
43
|
+
|
44
|
+
def supports_refresh_token?
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
def find_authorization_code(code)
|
49
|
+
StandardId::AuthorizationCode.lookup(code)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class AuthorizationFlow < BaseRequestFlow
|
4
|
+
attr_reader :params, :request, :current_account
|
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
|
+
[:response_type]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def execute
|
19
|
+
validate_params!
|
20
|
+
authenticate_client!
|
21
|
+
generate_authorization_response
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def validate_params!
|
27
|
+
if params[:response_type].blank?
|
28
|
+
raise StandardId::InvalidRequestError, "The response_type parameter is required"
|
29
|
+
end
|
30
|
+
|
31
|
+
if params[:client_id].blank?
|
32
|
+
raise StandardId::InvalidRequestError, "The client_id parameter is required"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def authenticate_client!
|
37
|
+
@client = StandardId::ClientApplication.active.find_by(client_id: params[:client_id])
|
38
|
+
unless @client
|
39
|
+
raise StandardId::InvalidClientError, "Invalid client_id"
|
40
|
+
end
|
41
|
+
|
42
|
+
# TODO: support for secret key rotation
|
43
|
+
# Maintain @client_credential for downstream compatibility (select any active secret)
|
44
|
+
@client_credential = @client.primary_client_secret
|
45
|
+
|
46
|
+
if params[:redirect_uri].present? && !@client.valid_redirect_uri?(params[:redirect_uri])
|
47
|
+
raise StandardId::InvalidRequestError, "Invalid redirect_uri"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def generate_authorization_response
|
52
|
+
raise NotImplementedError, "Subclasses must implement generate_authorization_response"
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_redirect_uri(base_uri, params_hash)
|
56
|
+
uri = URI.parse(base_uri)
|
57
|
+
query_params = URI.decode_www_form(uri.query || "")
|
58
|
+
|
59
|
+
params_hash.each do |key, value|
|
60
|
+
query_params << [key.to_s, value.to_s] if value.present?
|
61
|
+
end
|
62
|
+
|
63
|
+
uri.query = URI.encode_www_form(query_params)
|
64
|
+
uri.to_s
|
65
|
+
end
|
66
|
+
|
67
|
+
def build_fragment_uri(base_uri, params_hash)
|
68
|
+
uri = URI.parse(base_uri)
|
69
|
+
fragment_params = params_hash.compact.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
|
70
|
+
uri.fragment = fragment_params
|
71
|
+
uri.to_s
|
72
|
+
end
|
73
|
+
|
74
|
+
def redirect_uri
|
75
|
+
params[:redirect_uri] || @client&.redirect_uris_array&.first
|
76
|
+
end
|
77
|
+
|
78
|
+
def state
|
79
|
+
params[:state]
|
80
|
+
end
|
81
|
+
|
82
|
+
def scope
|
83
|
+
params[:scope]
|
84
|
+
end
|
85
|
+
|
86
|
+
def audience
|
87
|
+
params[:audience]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
# Shared base for all OAuth flows to handle params DSL and request context
|
4
|
+
# Used by both token grant flows and authorization endpoint flows
|
5
|
+
class BaseRequestFlow
|
6
|
+
attr_reader :params, :request, :current_account
|
7
|
+
|
8
|
+
def initialize(params, request, current_account: nil)
|
9
|
+
@params = params
|
10
|
+
@request = request
|
11
|
+
@current_account = current_account
|
12
|
+
end
|
13
|
+
|
14
|
+
class << self
|
15
|
+
def expect_params(*keys)
|
16
|
+
@expected_params ||= []
|
17
|
+
@expected_params |= keys.flatten.map! { |k| k.to_sym }
|
18
|
+
end
|
19
|
+
|
20
|
+
def permit_params(*keys)
|
21
|
+
@permitted_params ||= []
|
22
|
+
@permitted_params |= keys.flatten.map! { |k| k.to_sym }
|
23
|
+
end
|
24
|
+
|
25
|
+
def expected_params
|
26
|
+
Array(@expected_params).dup
|
27
|
+
end
|
28
|
+
|
29
|
+
# Subclasses can append additional keys by overriding extra_permitted_keys
|
30
|
+
def permitted_params
|
31
|
+
exp = expected_params
|
32
|
+
perm = Array(@permitted_params)
|
33
|
+
configured = (exp + perm + Array(extra_permitted_keys)).uniq
|
34
|
+
configured
|
35
|
+
end
|
36
|
+
|
37
|
+
def extra_permitted_keys
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module StandardId
|
2
|
+
module Oauth
|
3
|
+
class ClientCredentialsFlow < TokenGrantFlow
|
4
|
+
expect_params :client_id, :client_secret, :audience
|
5
|
+
permit_params :organization
|
6
|
+
|
7
|
+
def authenticate!
|
8
|
+
@credential = validate_client_secret!(params[:client_id], params[:client_secret])
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def subject_id
|
14
|
+
@credential.account_id
|
15
|
+
end
|
16
|
+
|
17
|
+
def client_id
|
18
|
+
@credential.client_id
|
19
|
+
end
|
20
|
+
|
21
|
+
def token_scope
|
22
|
+
@credential.scopes
|
23
|
+
end
|
24
|
+
|
25
|
+
def grant_type
|
26
|
+
"client_credentials"
|
27
|
+
end
|
28
|
+
|
29
|
+
def audience
|
30
|
+
params[:audience]
|
31
|
+
end
|
32
|
+
|
33
|
+
def token_expiry
|
34
|
+
1.hour
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|