standard_id 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +44 -1
  3. data/app/controllers/concerns/standard_id/api_authentication.rb +4 -0
  4. data/app/controllers/concerns/standard_id/social_authentication.rb +105 -0
  5. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +68 -0
  6. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +67 -72
  7. data/app/controllers/standard_id/web/login_controller.rb +20 -17
  8. data/app/controllers/standard_id/web/signup_controller.rb +1 -1
  9. data/app/views/standard_id/web/auth/callback/providers/apple_mobile.html.erb +50 -0
  10. data/app/views/standard_id/web/login/show.html.erb +3 -3
  11. data/app/views/standard_id/web/signup/show.html.erb +1 -1
  12. data/config/routes/api.rb +1 -1
  13. data/config/routes/web.rb +1 -0
  14. data/lib/generators/standard_id/install/templates/standard_id.rb +26 -0
  15. data/lib/standard_config/config.rb +4 -1
  16. data/lib/standard_id/api/authentication_guard.rb +36 -0
  17. data/lib/standard_id/config/schema.rb +6 -0
  18. data/lib/standard_id/http_client.rb +22 -0
  19. data/lib/standard_id/jwt_service.rb +19 -5
  20. data/lib/standard_id/oauth/authorization_code_flow.rb +8 -0
  21. data/lib/standard_id/oauth/client_credentials_flow.rb +8 -0
  22. data/lib/standard_id/oauth/password_flow.rb +4 -0
  23. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  24. data/lib/standard_id/oauth/social_flow.rb +61 -0
  25. data/lib/standard_id/oauth/subflows/social_login_grant.rb +9 -19
  26. data/lib/standard_id/oauth/token_grant_flow.rb +54 -1
  27. data/lib/standard_id/social_providers/apple.rb +184 -0
  28. data/lib/standard_id/social_providers/google.rb +168 -0
  29. data/lib/standard_id/social_providers/response_builder.rb +18 -0
  30. data/lib/standard_id/utils/callable_parameter_filter.rb +36 -0
  31. data/lib/standard_id/version.rb +1 -1
  32. data/lib/standard_id.rb +5 -0
  33. metadata +10 -2
  34. data/app/controllers/standard_id/api/providers_controller.rb +0 -174
@@ -23,9 +23,10 @@ module StandardConfig
23
23
  # If set, Authorization endpoints can redirect to this path with a redirect_uri param
24
24
  attr_accessor :login_url
25
25
 
26
- # Social login provider credentials
26
+ # Social login provider credentials and hooks
27
27
  attr_accessor :google_client_id, :google_client_secret
28
28
  attr_accessor :apple_client_id, :apple_client_secret, :apple_private_key, :apple_key_id, :apple_team_id
29
+ attr_accessor :social_account_attributes, :social_callback
29
30
 
30
31
  # Passwordless authentication callbacks
31
32
  # These should be callable objects (procs/lambdas) that accept (recipient, code) parameters
@@ -54,6 +55,8 @@ module StandardConfig
54
55
  @apple_private_key = nil
55
56
  @apple_key_id = nil
56
57
  @apple_team_id = nil
58
+ @social_account_attributes = nil
59
+ @social_callback = nil
57
60
  @passwordless_email_sender = nil
58
61
  @passwordless_sms_sender = nil
59
62
  @allowed_post_logout_redirect_uris = []
@@ -15,6 +15,42 @@ module StandardId
15
15
 
16
16
  api_session
17
17
  end
18
+
19
+ def require_scopes!(session_manager, *required_scopes)
20
+ api_session = require_session!(session_manager)
21
+
22
+ expected_scopes = normalize_scopes(required_scopes)
23
+ return api_session if expected_scopes.empty?
24
+
25
+ token_scopes = extract_session_scopes(api_session)
26
+ unless (token_scopes & expected_scopes).any?
27
+ raise StandardId::InvalidScopeError,
28
+ "Access token missing required scope. Requires one of: #{expected_scopes.join(', ')}"
29
+ end
30
+
31
+ api_session
32
+ end
33
+
34
+ private
35
+
36
+ def extract_session_scopes(api_session)
37
+ api_session&.scopes || []
38
+ end
39
+
40
+ def normalize_scopes(required_scopes)
41
+ return [] if required_scopes.nil?
42
+
43
+ case required_scopes
44
+ when String
45
+ [required_scopes]
46
+ when Symbol
47
+ [required_scopes.to_s]
48
+ when Array
49
+ required_scopes.flat_map { |value| normalize_scopes(value) }.uniq
50
+ else
51
+ raise ArgumentError, "Scopes must be provided as a String, Symbol, or Array"
52
+ end
53
+ end
18
54
  end
19
55
  end
20
56
  end
@@ -35,6 +35,8 @@ StandardConfig.schema.draw do
35
35
  field :token_lifetimes, type: :hash, default: -> { {} }
36
36
  field :client_id, type: :string, default: nil
37
37
  field :client_secret, type: :string, default: nil
38
+ field :scope_claims, type: :hash, default: -> { {} }
39
+ field :claim_resolvers, type: :hash, default: -> { {} }
38
40
  end
39
41
 
40
42
  scope :social do
@@ -45,5 +47,9 @@ StandardConfig.schema.draw do
45
47
  field :apple_private_key, type: :string, default: nil
46
48
  field :apple_key_id, type: :string, default: nil
47
49
  field :apple_team_id, type: :string, default: nil
50
+ field :apple_mobile_client_id, type: :string, default: nil
51
+ field :social_account_attributes, type: :any, default: nil
52
+ field :allowed_redirect_url_prefixes, type: :array, default: []
53
+ field :social_callback, type: :any, default: nil
48
54
  end
49
55
  end
@@ -0,0 +1,22 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module StandardId
5
+ class HttpClient
6
+ class << self
7
+ def post_form(endpoint, params)
8
+ uri = URI(endpoint)
9
+ Net::HTTP.post_form(uri, params)
10
+ end
11
+
12
+ def get_with_bearer(endpoint, access_token)
13
+ uri = URI(endpoint)
14
+ request = Net::HTTP::Get.new(uri)
15
+ request["Authorization"] = "Bearer #{access_token}"
16
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
17
+ http.request(request)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -3,9 +3,14 @@ require "jwt"
3
3
  module StandardId
4
4
  class JwtService
5
5
  ALGORITHM = "HS256"
6
- Session = Struct.new(:account_id, :client_id, :scopes, :grant_type, keyword_init: true) do
7
- def active?
8
- true
6
+ RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
7
+ BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type]
8
+
9
+ def self.session_class
10
+ Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
11
+ def active?
12
+ true
13
+ end
9
14
  end
10
15
  end
11
16
 
@@ -33,11 +38,12 @@ module StandardId
33
38
  Array(payload[:scope]).compact
34
39
  end
35
40
 
36
- Session.new(
41
+ session_class.new(
42
+ **payload.slice(*claim_resolver_keys),
37
43
  account_id: payload[:sub],
38
44
  client_id: payload[:client_id],
39
45
  scopes: scopes,
40
- grant_type: payload[:grant_type]
46
+ grant_type: payload[:grant_type],
41
47
  )
42
48
  end
43
49
 
@@ -46,5 +52,13 @@ module StandardId
46
52
  def self.secret_key
47
53
  Rails.application.secret_key_base
48
54
  end
55
+
56
+ def self.claim_resolver_keys
57
+ resolvers = StandardId.config.oauth.claim_resolvers
58
+ keys = Hash.try_convert(resolvers)&.keys
59
+ keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS)
60
+ rescue StandardError
61
+ []
62
+ end
49
63
  end
50
64
  end
@@ -48,6 +48,14 @@ module StandardId
48
48
  def find_authorization_code(code)
49
49
  StandardId::AuthorizationCode.lookup(code)
50
50
  end
51
+
52
+ def token_client
53
+ @credential&.client_application
54
+ end
55
+
56
+ def token_account
57
+ @authorization_code&.account
58
+ end
51
59
  end
52
60
  end
53
61
  end
@@ -29,6 +29,14 @@ module StandardId
29
29
  def audience
30
30
  params[:audience]
31
31
  end
32
+
33
+ def token_client
34
+ @credential&.client_application
35
+ end
36
+
37
+ def token_account
38
+ nil
39
+ end
32
40
  end
33
41
  end
34
42
  end
@@ -61,6 +61,10 @@ module StandardId
61
61
  def default_scope
62
62
  "read"
63
63
  end
64
+
65
+ def token_account
66
+ @account
67
+ end
64
68
  end
65
69
  end
66
70
  end
@@ -79,6 +79,10 @@ module StandardId
79
79
  def default_scope
80
80
  "read"
81
81
  end
82
+
83
+ def token_account
84
+ account
85
+ end
82
86
  end
83
87
  end
84
88
  end
@@ -0,0 +1,61 @@
1
+ module StandardId
2
+ module Oauth
3
+ class SocialFlow < TokenGrantFlow
4
+ attr_reader :account, :connection, :original_params
5
+
6
+ def initialize(params, request, account:, connection:, original_params: {})
7
+ super(params, request)
8
+ @account = account
9
+ @connection = connection
10
+ @original_params = original_params
11
+ end
12
+
13
+ def authenticate!
14
+ raise StandardId::InvalidGrantError, "Account is required for social flow" if @account.blank?
15
+ end
16
+
17
+ private
18
+
19
+ def subject_id
20
+ @account.id
21
+ end
22
+
23
+ def client_id
24
+ @original_params["client_id"]
25
+ end
26
+
27
+ def token_scope
28
+ @original_params["scope"]
29
+ end
30
+
31
+ def grant_type
32
+ "social"
33
+ end
34
+
35
+ def audience
36
+ @original_params["audience"]
37
+ end
38
+
39
+ def supports_refresh_token?
40
+ true
41
+ end
42
+
43
+ def token_lifetime_key
44
+ :social
45
+ end
46
+
47
+ def token_account
48
+ @account
49
+ end
50
+
51
+ def token_client
52
+ nil
53
+ end
54
+
55
+ def build_jwt_payload(expires_in)
56
+ base_payload = super(expires_in)
57
+ base_payload.merge(provider: @connection).compact
58
+ end
59
+ end
60
+ end
61
+ end
@@ -10,7 +10,7 @@ module StandardId
10
10
 
11
11
  def social_provider_url
12
12
  @social_provider_url ||= case params[:connection]
13
- when "google-oauth2"
13
+ when "google"
14
14
  build_google_oauth_url
15
15
  when "apple"
16
16
  build_apple_oauth_url
@@ -20,28 +20,18 @@ module StandardId
20
20
  end
21
21
 
22
22
  def build_google_oauth_url
23
- google_params = {
24
- client_id: StandardId.config.google_client_id,
23
+ StandardId::SocialProviders::Google.authorization_url(
24
+ state: encode_state_with_original_params,
25
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)
26
+ scope: "openid email profile"
27
+ )
32
28
  end
33
29
 
34
30
  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)
31
+ StandardId::SocialProviders::Apple.authorization_url(
32
+ state: encode_state_with_original_params,
33
+ redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple"
34
+ )
45
35
  end
46
36
 
47
37
  def encode_state_with_original_params
@@ -52,13 +52,15 @@ module StandardId
52
52
  end
53
53
 
54
54
  def build_jwt_payload(expires_in)
55
- {
55
+ base_payload = {
56
56
  sub: subject_id,
57
57
  client_id: client_id,
58
58
  scope: token_scope,
59
59
  grant_type: grant_type,
60
60
  aud: audience
61
61
  }.compact
62
+
63
+ base_payload.merge(claims_from_scope_mapping)
62
64
  end
63
65
 
64
66
  def token_expiry
@@ -106,6 +108,57 @@ module StandardId
106
108
  def audience
107
109
  params[:audience]
108
110
  end
111
+
112
+ def claims_from_scope_mapping
113
+ scope_claims = StandardId.config.oauth.scope_claims.with_indifferent_access
114
+ resolvers = StandardId.config.oauth.claim_resolvers.with_indifferent_access
115
+ return {} if scope_claims.empty? || resolvers.empty?
116
+
117
+ claims = {}
118
+ current_scopes.each do |scope|
119
+ Array(scope_claims[scope]).each do |claim_key|
120
+ next if claims.key?(claim_key)
121
+
122
+ value = resolve_claim_value(resolvers[claim_key])
123
+ claims[claim_key] = value unless value.nil?
124
+ end
125
+ end
126
+
127
+ claims.compact.symbolize_keys
128
+ end
129
+
130
+ def current_scopes
131
+ Array.wrap(token_scope)
132
+ .flat_map { |value| value.to_s.split(/\s+/) }
133
+ .reject(&:blank?)
134
+ .uniq
135
+ end
136
+
137
+ def token_account
138
+ return nil if subject_id.blank?
139
+
140
+ account_class = StandardId.account_class
141
+ return nil unless account_class.respond_to?(:find_by)
142
+
143
+ account_class.find_by(id: subject_id)
144
+ end
145
+
146
+ def token_client
147
+ StandardId::ClientApplication.find_by(client_id: client_id)
148
+ end
149
+
150
+ def claim_resolvers_context
151
+ @claim_resolvers_context ||= {
152
+ client: token_client,
153
+ account: token_account,
154
+ request: request
155
+ }
156
+ end
157
+
158
+ def resolve_claim_value(resolver)
159
+ filtered_context = StandardId::Utils::CallableParameterFilter.filter(resolver, claim_resolvers_context)
160
+ resolver.call(**filtered_context)
161
+ end
109
162
  end
110
163
  end
111
164
  end
@@ -0,0 +1,184 @@
1
+ require "uri"
2
+ require "net/http"
3
+ require "json"
4
+ require "jwt"
5
+ require_relative "response_builder"
6
+
7
+ module StandardId
8
+ module SocialProviders
9
+ class Apple
10
+ include ResponseBuilder
11
+
12
+ ISSUER = "https://appleid.apple.com".freeze
13
+ AUTH_ENDPOINT = "#{ISSUER}/auth/authorize".freeze
14
+ TOKEN_ENDPOINT = "#{ISSUER}/auth/token".freeze
15
+ JWKS_URI = "#{ISSUER}/auth/keys".freeze
16
+ DEFAULT_SCOPE = "name email".freeze
17
+ DEFAULT_RESPONSE_MODE = "form_post".freeze
18
+
19
+ class << self
20
+ def authorization_url(state:, redirect_uri:, scope: DEFAULT_SCOPE, response_mode: DEFAULT_RESPONSE_MODE)
21
+ ensure_basic_credentials!
22
+
23
+ query = {
24
+ client_id: StandardId.config.apple_client_id,
25
+ redirect_uri: redirect_uri,
26
+ response_type: "code",
27
+ scope: scope,
28
+ response_mode: response_mode,
29
+ state: state
30
+ }
31
+
32
+ "#{AUTH_ENDPOINT}?#{URI.encode_www_form(query)}"
33
+ end
34
+
35
+ def get_user_info(code: nil, id_token: nil, redirect_uri: nil, client_id: StandardId.config.apple_client_id)
36
+ if id_token.present?
37
+ build_response(
38
+ verify_id_token(id_token: id_token, client_id: client_id),
39
+ tokens: { id_token: id_token }
40
+ )
41
+ elsif code.present?
42
+ exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, client_id: client_id)
43
+ else
44
+ raise StandardId::InvalidRequestError, "Either code or id_token must be provided"
45
+ end
46
+ end
47
+
48
+ def exchange_code_for_user_info(code:, redirect_uri:, client_id: StandardId.config.apple_client_id)
49
+ ensure_full_credentials!(client_id: client_id)
50
+ raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
51
+
52
+ token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
53
+ client_id: client_id,
54
+ client_secret: generate_client_secret(client_id: client_id),
55
+ code: code,
56
+ grant_type: "authorization_code",
57
+ redirect_uri: redirect_uri
58
+ })
59
+
60
+ unless token_response.is_a?(Net::HTTPSuccess)
61
+ error_body = JSON.parse(token_response.body) rescue {}
62
+ raise StandardId::InvalidRequestError, "Failed to exchange Apple authorization code: #{error_body['error']}"
63
+ end
64
+
65
+ parsed_token = JSON.parse(token_response.body)
66
+ id_token = parsed_token["id_token"]
67
+ raise StandardId::InvalidRequestError, "Apple response missing id_token" if id_token.blank?
68
+
69
+ tokens = extract_token_payload(parsed_token)
70
+ user_info = verify_id_token(id_token: id_token, client_id: client_id)
71
+
72
+ build_response(user_info, tokens: tokens)
73
+ rescue StandardError => e
74
+ raise e if e.is_a?(StandardId::OAuthError)
75
+ raise StandardId::OAuthError, e.message, cause: e
76
+ end
77
+
78
+ def verify_id_token(id_token:, client_id: StandardId.config.apple_client_id)
79
+ raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
80
+ if client_id.blank?
81
+ raise StandardId::InvalidRequestError, "Apple client_id is not configured"
82
+ end
83
+
84
+ decoded_token = JWT.decode(id_token, nil, false)
85
+ header = decoded_token[1]
86
+
87
+ jwk = fetch_jwk(kid: header["kid"])
88
+
89
+ verified_payload, = JWT.decode(
90
+ id_token,
91
+ jwk.public_key,
92
+ true,
93
+ algorithm: "RS256",
94
+ iss: ISSUER,
95
+ verify_iss: true,
96
+ aud: client_id,
97
+ verify_aud: true
98
+ )
99
+
100
+ {
101
+ "sub" => verified_payload["sub"],
102
+ "email" => verified_payload["email"],
103
+ "email_verified" => verified_payload["email_verified"],
104
+ "is_private_email" => verified_payload["is_private_email"]
105
+ }.compact
106
+ rescue JWT::InvalidAudError => e
107
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token audience: #{e.message}"
108
+ rescue JWT::DecodeError => e
109
+ raise StandardId::InvalidRequestError, "Invalid Apple ID token: #{e.message}"
110
+ rescue StandardError => e
111
+ raise e if e.is_a?(StandardId::OAuthError)
112
+ raise StandardId::OAuthError, e.message, cause: e
113
+ end
114
+
115
+ private
116
+
117
+ def ensure_basic_credentials!(client_id: StandardId.config.apple_client_id)
118
+ if client_id.blank?
119
+ raise StandardId::InvalidRequestError, "Apple OAuth is not configured"
120
+ end
121
+ end
122
+
123
+ def ensure_full_credentials!(client_id: nil)
124
+ ensure_basic_credentials!(client_id: client_id)
125
+
126
+ required = [
127
+ StandardId.config.apple_private_key,
128
+ StandardId.config.apple_key_id,
129
+ StandardId.config.apple_team_id
130
+ ]
131
+
132
+ if required.any?(&:blank?)
133
+ raise StandardId::InvalidRequestError, "Apple OAuth credentials are incomplete"
134
+ end
135
+ end
136
+
137
+ def generate_client_secret(client_id: StandardId.config.apple_client_id)
138
+ header = {
139
+ alg: "ES256",
140
+ kid: StandardId.config.apple_key_id
141
+ }
142
+
143
+ payload = {
144
+ iss: StandardId.config.apple_team_id,
145
+ iat: Time.current.to_i,
146
+ exp: Time.current.to_i + 3600,
147
+ aud: ISSUER,
148
+ sub: client_id
149
+ }
150
+
151
+ private_key = OpenSSL::PKey::EC.new(StandardId.config.apple_private_key)
152
+ JWT.encode(payload, private_key, "ES256", header)
153
+ end
154
+
155
+ def fetch_jwk(kid:)
156
+ uri = URI(JWKS_URI)
157
+ jwks_response = Net::HTTP.get_response(uri)
158
+
159
+ unless jwks_response.is_a?(Net::HTTPSuccess)
160
+ raise StandardId::InvalidRequestError, "Failed to fetch Apple JWKS"
161
+ end
162
+
163
+ jwks_data = JSON.parse(jwks_response.body)
164
+ jwk_data = jwks_data["keys"].find { |key| key["kid"] == kid }
165
+
166
+ raise StandardId::InvalidRequestError, "JWK with kid '#{kid}' not found in Apple's JWKS" unless jwk_data
167
+
168
+ JWT::JWK.import(jwk_data)
169
+ rescue StandardError => e
170
+ raise e if e.is_a?(StandardId::OAuthError)
171
+ raise StandardId::OAuthError, "Failed to fetch JWK: #{e.message}"
172
+ end
173
+
174
+ def extract_token_payload(parsed_token)
175
+ {
176
+ access_token: parsed_token["access_token"],
177
+ refresh_token: parsed_token["refresh_token"],
178
+ id_token: parsed_token["id_token"]
179
+ }.compact
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end