standard_id 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7f12cdcd431146c994f1ae5b1bf0919357fc7abf8e3668d13363e22eb421fe0
4
- data.tar.gz: 2017963a39eb1430206fdc8ebf8354c12fc9fa46b755a7706452cb06ac6352b1
3
+ metadata.gz: f8e7433ee5b0ef4a2da21afc993c2106cac0d69d03af4fb842f5ab2d8cf2e71b
4
+ data.tar.gz: 7a4eb0f649f83157a77a1200d249eb79391de044470caad81c2bbbd2cc0906b1
5
5
  SHA512:
6
- metadata.gz: 61b5e5a16dca753e8128300fb88b37e931252f05e73cfccc5fbc7467e4c58eca7435bc004877eca56c59305310de40c56b0491856cc173cddd5c815bf72b42c9
7
- data.tar.gz: 1af3914e27993f6a51bb15fc821f40c8bd2095744e0187b107a2273ca70ab006e4c9a5522c3001db6f7f1240345e744fc7805ba5970410f2f263382bbb8c36cd
6
+ metadata.gz: fd06a2fa4d70147dadaa175cfd371861a0d723ec38b79cf4991c5d7dab0fb29667f08378654ec17a382673575af501b53d7bd84a1dc750766985e58326639abb
7
+ data.tar.gz: 6686d5ced7aad0df56e585b454e614bb1ddb941a97c7843c97907dfa3ad05682e699a610ee25c31242b2dc8347827991c8e5f41b3a226e2a0c3c314d7bd5ff47
@@ -0,0 +1,48 @@
1
+ module StandardId
2
+ module Api
3
+ module Oauth
4
+ class RevocationsController < BaseController
5
+ public_controller
6
+
7
+ skip_before_action :validate_content_type!
8
+
9
+ # POST /oauth/revoke
10
+ # RFC 7009 - OAuth 2.0 Token Revocation
11
+ #
12
+ # Accepts a token and optional token_type_hint parameter.
13
+ # Always responds with 200 OK regardless of whether the token
14
+ # was valid or revocation was successful (per RFC 7009 Section 2.1).
15
+ def create
16
+ token = params[:token]
17
+ head :ok and return if token.blank?
18
+
19
+ payload = StandardId::JwtService.decode(token)
20
+ head :ok and return unless payload&.dig(:sub)
21
+
22
+ account_id = payload[:sub]
23
+
24
+ sessions = StandardId::DeviceSession
25
+ .where(account_id: account_id)
26
+ .active
27
+
28
+ # token_type_hint is accepted but ignored — we always attempt
29
+ # revocation via sub claim regardless of token type (RFC 7009 §2.1)
30
+ revoked_sessions = sessions.to_a
31
+ if revoked_sessions.any?
32
+ ActiveRecord::Base.transaction do
33
+ revoked_sessions.each { |session| session.revoke!(reason: "token_revocation") }
34
+ end
35
+
36
+ StandardId::Events.publish(
37
+ StandardId::Events::OAUTH_TOKEN_REVOKED,
38
+ account_id: account_id,
39
+ sessions_revoked: revoked_sessions.size
40
+ )
41
+ end
42
+
43
+ head :ok
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,42 @@
1
+ module StandardId
2
+ module Api
3
+ class SessionsController < BaseController
4
+ authenticated_controller
5
+
6
+ skip_before_action :validate_content_type!
7
+ before_action :verify_access_token!
8
+
9
+ def index
10
+ sessions = current_account.sessions.active.order(created_at: :desc)
11
+
12
+ render json: sessions.map { |session| serialize_session(session) }
13
+ end
14
+
15
+ def destroy
16
+ session = current_account.sessions.find_by(id: params[:id])
17
+
18
+ unless session
19
+ render json: { error: "not_found", error_description: "Session not found" }, status: :not_found
20
+ return
21
+ end
22
+
23
+ session.revoke!(reason: "api_revocation")
24
+ head :no_content
25
+ end
26
+
27
+ private
28
+
29
+ def serialize_session(session)
30
+ {
31
+ id: session.id,
32
+ type: session.type&.demodulize,
33
+ created_at: session.created_at.iso8601,
34
+ last_refreshed_at: session.respond_to?(:last_refreshed_at) ? session.last_refreshed_at&.iso8601 : nil,
35
+ ip_address: session.respond_to?(:ip_address) ? session.ip_address : nil,
36
+ # user_agent is the API-facing name for the device_agent model attribute
37
+ user_agent: session.respond_to?(:device_agent) ? session.device_agent : nil
38
+ }.compact
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ module StandardId
2
+ module Api
3
+ module WellKnown
4
+ class OpenidConfigurationController < ActionController::API
5
+ include StandardId::ControllerPolicy
6
+ public_controller
7
+
8
+ def show
9
+ issuer = StandardId.config.issuer
10
+
11
+ unless issuer.present?
12
+ render json: { error: "Issuer not configured" }, status: :not_found
13
+ return
14
+ end
15
+
16
+ response.headers["Cache-Control"] = "public, max-age=3600"
17
+ render json: discovery_document(issuer)
18
+ end
19
+
20
+ private
21
+
22
+ def discovery_document(issuer)
23
+ base = issuer.chomp("/")
24
+
25
+ {
26
+ issuer: issuer,
27
+ authorization_endpoint: "#{base}/authorize",
28
+ token_endpoint: "#{base}/oauth/token",
29
+ revocation_endpoint: "#{base}/oauth/revoke",
30
+ userinfo_endpoint: "#{base}/userinfo",
31
+ jwks_uri: "#{base}/.well-known/jwks.json",
32
+ response_types_supported: %w[code],
33
+ grant_types_supported: %w[authorization_code refresh_token client_credentials],
34
+ subject_types_supported: %w[public],
35
+ id_token_signing_alg_values_supported: [StandardId.config.oauth.signing_algorithm.to_s.upcase],
36
+ token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post]
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
data/config/routes/api.rb CHANGED
@@ -4,6 +4,8 @@ StandardId::ApiEngine.routes.draw do
4
4
 
5
5
  resource :userinfo, only: [:show], controller: :userinfo
6
6
 
7
+ resources :sessions, only: [:index, :destroy]
8
+
7
9
  resource :passwordless, only: [], controller: :passwordless do
8
10
  post :start
9
11
  end
@@ -14,6 +16,7 @@ StandardId::ApiEngine.routes.draw do
14
16
 
15
17
  namespace :oauth do
16
18
  resource :token, only: [:create]
19
+ resource :revoke, only: [:create], controller: :revocations
17
20
 
18
21
  namespace :callback do
19
22
  post ":provider", to: "providers#callback", as: :provider
@@ -22,6 +25,7 @@ StandardId::ApiEngine.routes.draw do
22
25
 
23
26
  scope ".well-known", module: :well_known do
24
27
  get "jwks.json", to: "jwks#show", as: :jwks
28
+ get "openid-configuration", to: "openid_configuration#show", as: :openid_configuration
25
29
  end
26
30
  end
27
31
  end
@@ -33,6 +33,7 @@ StandardConfig.schema.draw do
33
33
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
34
34
  field :max_attempts, type: :integer, default: 3
35
35
  field :retry_delay, type: :integer, default: 30 # 30 seconds
36
+ field :bypass_code, type: :string, default: nil
36
37
  end
37
38
 
38
39
  scope :password do
@@ -39,6 +39,7 @@ module StandardId
39
39
  OAUTH_TOKEN_ISSUED = "oauth.token.issued"
40
40
  OAUTH_TOKEN_REFRESHED = "oauth.token.refreshed"
41
41
  OAUTH_CODE_CONSUMED = "oauth.code.consumed"
42
+ OAUTH_TOKEN_REVOKED = "oauth.token.revoked"
42
43
 
43
44
  PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
44
45
  PASSWORDLESS_CODE_GENERATED = "passwordless.code.generated"
@@ -108,7 +109,8 @@ module StandardId
108
109
  OAUTH_TOKEN_ISSUING,
109
110
  OAUTH_TOKEN_ISSUED,
110
111
  OAUTH_TOKEN_REFRESHED,
111
- OAUTH_CODE_CONSUMED
112
+ OAUTH_CODE_CONSUMED,
113
+ OAUTH_TOKEN_REVOKED
112
114
  ].freeze
113
115
 
114
116
  PASSWORDLESS_EVENTS = [
@@ -164,6 +166,7 @@ module StandardId
164
166
  OAUTH_AUTHORIZATION_DENIED,
165
167
  OAUTH_TOKEN_ISSUED,
166
168
  OAUTH_TOKEN_REFRESHED,
169
+ OAUTH_TOKEN_REVOKED,
167
170
  # Passwordless
168
171
  PASSWORDLESS_CODE_FAILED,
169
172
  PASSWORDLESS_ACCOUNT_CREATED,
@@ -83,6 +83,9 @@ module StandardId
83
83
  return failure("Code is required")
84
84
  end
85
85
 
86
+ bypass_result = try_bypass
87
+ return bypass_result if bypass_result
88
+
86
89
  challenge = find_active_challenge
87
90
  code_matches = challenge.present? && secure_compare(challenge.code, @code)
88
91
  attempts = record_failed_attempt(challenge, code_matches)
@@ -127,6 +130,20 @@ module StandardId
127
130
 
128
131
  private
129
132
 
133
+ # When a bypass_code is configured and the submitted code matches,
134
+ # skip the CodeChallenge lookup entirely. This allows E2E testing
135
+ # tools (e.g. Playwright) to verify OTPs without a real challenge.
136
+ def try_bypass
137
+ bypass_code = StandardId.config.passwordless.bypass_code
138
+ return unless bypass_code.present?
139
+ return unless secure_compare(bypass_code, @code)
140
+
141
+ strategy = strategy_for(@channel)
142
+ account = strategy.find_or_create_account(@target)
143
+
144
+ success(account: account, challenge: nil)
145
+ end
146
+
130
147
  def resolve_target_and_channel!(email, phone)
131
148
  if email.present?
132
149
  @target = email.to_s.strip
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -122,11 +122,14 @@ files:
122
122
  - app/controllers/standard_id/api/base_controller.rb
123
123
  - app/controllers/standard_id/api/oauth/base_controller.rb
124
124
  - app/controllers/standard_id/api/oauth/callback/providers_controller.rb
125
+ - app/controllers/standard_id/api/oauth/revocations_controller.rb
125
126
  - app/controllers/standard_id/api/oauth/tokens_controller.rb
126
127
  - app/controllers/standard_id/api/oidc/logout_controller.rb
127
128
  - app/controllers/standard_id/api/passwordless_controller.rb
129
+ - app/controllers/standard_id/api/sessions_controller.rb
128
130
  - app/controllers/standard_id/api/userinfo_controller.rb
129
131
  - app/controllers/standard_id/api/well_known/jwks_controller.rb
132
+ - app/controllers/standard_id/api/well_known/openid_configuration_controller.rb
130
133
  - app/controllers/standard_id/web/account_controller.rb
131
134
  - app/controllers/standard_id/web/auth/callback/providers_controller.rb
132
135
  - app/controllers/standard_id/web/base_controller.rb