token_authority 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +199 -7
- data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
- data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
- data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
- data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
- data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
- data/app/controllers/token_authority/authorizations_controller.rb +105 -0
- data/app/controllers/token_authority/clients_controller.rb +99 -0
- data/app/controllers/token_authority/metadata_controller.rb +12 -0
- data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
- data/app/controllers/token_authority/sessions_controller.rb +228 -0
- data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
- data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
- data/app/models/concerns/token_authority/event_logging.rb +144 -0
- data/app/models/concerns/token_authority/resourceable.rb +111 -0
- data/app/models/concerns/token_authority/scopeable.rb +105 -0
- data/app/models/concerns/token_authority/session_creatable.rb +101 -0
- data/app/models/token_authority/access_token.rb +127 -0
- data/app/models/token_authority/access_token_request.rb +193 -0
- data/app/models/token_authority/authorization_grant.rb +119 -0
- data/app/models/token_authority/authorization_request.rb +276 -0
- data/app/models/token_authority/authorization_server_metadata.rb +101 -0
- data/app/models/token_authority/client.rb +263 -0
- data/app/models/token_authority/client_id_resolver.rb +114 -0
- data/app/models/token_authority/client_metadata_document.rb +164 -0
- data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
- data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
- data/app/models/token_authority/client_registration_request.rb +214 -0
- data/app/models/token_authority/client_registration_response.rb +58 -0
- data/app/models/token_authority/jwks_cache.rb +37 -0
- data/app/models/token_authority/jwks_fetcher.rb +70 -0
- data/app/models/token_authority/protected_resource_metadata.rb +74 -0
- data/app/models/token_authority/refresh_token.rb +110 -0
- data/app/models/token_authority/refresh_token_request.rb +116 -0
- data/app/models/token_authority/session.rb +193 -0
- data/app/models/token_authority/software_statement.rb +70 -0
- data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
- data/app/views/token_authority/client_error.html.erb +8 -0
- data/config/locales/token_authority.en.yml +248 -0
- data/config/routes.rb +29 -0
- data/lib/generators/token_authority/install/install_generator.rb +61 -0
- data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
- data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
- data/lib/token_authority/configuration.rb +397 -0
- data/lib/token_authority/engine.rb +34 -0
- data/lib/token_authority/errors.rb +221 -0
- data/lib/token_authority/instrumentation.rb +80 -0
- data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
- data/lib/token_authority/json_web_token.rb +78 -0
- data/lib/token_authority/log_event_subscriber.rb +43 -0
- data/lib/token_authority/routing/constraints.rb +71 -0
- data/lib/token_authority/routing/routes.rb +39 -0
- data/lib/token_authority/version.rb +4 -1
- data/lib/token_authority.rb +30 -1
- metadata +65 -5
- data/app/assets/stylesheets/token_authority/application.css +0 -15
- data/app/controllers/token_authority/application_controller.rb +0 -4
- data/app/helpers/token_authority/application_helper.rb +0 -4
- data/app/views/layouts/token_authority/application.html.erb +0 -17
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Builds the RFC 9728 OAuth 2.0 Protected Resource Metadata response
|
|
6
|
+
class ProtectedResourceMetadata
|
|
7
|
+
def initialize(mount_path:)
|
|
8
|
+
@mount_path = mount_path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_h
|
|
12
|
+
metadata = {
|
|
13
|
+
resource: resource,
|
|
14
|
+
authorization_servers: authorization_servers
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
metadata[:scopes_supported] = scopes_supported if scopes_supported.any?
|
|
18
|
+
metadata[:bearer_methods_supported] = bearer_methods_supported if bearer_methods_supported.present?
|
|
19
|
+
metadata[:jwks_uri] = jwks_uri if jwks_uri.present?
|
|
20
|
+
metadata[:resource_name] = resource_name if resource_name.present?
|
|
21
|
+
metadata[:resource_documentation] = resource_documentation if resource_documentation.present?
|
|
22
|
+
metadata[:resource_policy_uri] = resource_policy_uri if resource_policy_uri.present?
|
|
23
|
+
metadata[:resource_tos_uri] = resource_tos_uri if resource_tos_uri.present?
|
|
24
|
+
|
|
25
|
+
metadata
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def config
|
|
31
|
+
TokenAuthority.config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def issuer
|
|
35
|
+
config.rfc_9068_issuer_url.to_s.chomp("/")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resource
|
|
39
|
+
config.rfc_9728_resource.presence || issuer
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def authorization_servers
|
|
43
|
+
config.rfc_9728_authorization_servers.presence || [issuer]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scopes_supported
|
|
47
|
+
config.rfc_9728_scopes_supported.presence || config.scopes&.keys || []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bearer_methods_supported
|
|
51
|
+
config.rfc_9728_bearer_methods_supported
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def jwks_uri
|
|
55
|
+
config.rfc_9728_jwks_uri
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resource_name
|
|
59
|
+
config.rfc_9728_resource_name
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resource_documentation
|
|
63
|
+
config.rfc_9728_resource_documentation
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resource_policy_uri
|
|
67
|
+
config.rfc_9728_resource_policy_uri
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resource_tos_uri
|
|
71
|
+
config.rfc_9728_resource_tos_uri
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Represents an OAuth 2.1 refresh token with JWT format.
|
|
5
|
+
#
|
|
6
|
+
# Refresh tokens are long-lived tokens used to obtain new access tokens without
|
|
7
|
+
# requiring user interaction. Like access tokens, they follow JWT format but have
|
|
8
|
+
# a longer expiration time (default: 14 days).
|
|
9
|
+
#
|
|
10
|
+
# Refresh tokens implement token rotation: each refresh operation creates a new
|
|
11
|
+
# session with new access and refresh tokens, and marks the old session as
|
|
12
|
+
# "refreshed". This prevents replay attacks.
|
|
13
|
+
#
|
|
14
|
+
# Unlike access tokens, refresh tokens do not include a user_id claim since they
|
|
15
|
+
# are looked up by JTI in the Session table which already has the user association.
|
|
16
|
+
#
|
|
17
|
+
# This is an ActiveModel object (not ActiveRecord) that provides validation
|
|
18
|
+
# and serialization of JWT claims without database persistence.
|
|
19
|
+
#
|
|
20
|
+
# @example Creating a refresh token
|
|
21
|
+
# token = TokenAuthority::RefreshToken.default(
|
|
22
|
+
# exp: 14.days.from_now,
|
|
23
|
+
# resources: ["https://api.example.com"],
|
|
24
|
+
# scopes: ["read", "write"]
|
|
25
|
+
# )
|
|
26
|
+
# jwt_string = token.to_encoded_token
|
|
27
|
+
#
|
|
28
|
+
# @example Decoding a refresh token
|
|
29
|
+
# token = TokenAuthority::RefreshToken.from_token(jwt_string)
|
|
30
|
+
# token.valid? # => true/false
|
|
31
|
+
#
|
|
32
|
+
# @since 0.2.0
|
|
33
|
+
class RefreshToken
|
|
34
|
+
include TokenAuthority::ClaimValidatable
|
|
35
|
+
|
|
36
|
+
# @!attribute [rw] scope
|
|
37
|
+
# Space-separated list of OAuth scopes for this refresh token.
|
|
38
|
+
# @return [String, nil]
|
|
39
|
+
attr_accessor :scope
|
|
40
|
+
|
|
41
|
+
# Creates a new refresh token with standard JWT claims.
|
|
42
|
+
#
|
|
43
|
+
# The audience (aud) claim is set from resource indicators if provided,
|
|
44
|
+
# otherwise falls back to the configured default audience URL.
|
|
45
|
+
#
|
|
46
|
+
# @param exp [Time, Integer] token expiration time
|
|
47
|
+
# @param resources [Array<String>] resource indicators (RFC 8707)
|
|
48
|
+
# @param scopes [Array<String>] OAuth scopes to include
|
|
49
|
+
#
|
|
50
|
+
# @return [TokenAuthority::RefreshToken] the new token instance
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# token = RefreshToken.default(
|
|
54
|
+
# exp: 14.days.from_now,
|
|
55
|
+
# resources: ["https://api.example.com"],
|
|
56
|
+
# scopes: ["read", "write"]
|
|
57
|
+
# )
|
|
58
|
+
def self.default(exp:, resources: [], scopes: [])
|
|
59
|
+
# Use resources for aud claim if provided, otherwise fall back to config
|
|
60
|
+
aud = if resources.any?
|
|
61
|
+
(resources.size == 1) ? resources.first : resources
|
|
62
|
+
else
|
|
63
|
+
TokenAuthority.config.rfc_9068_audience_url
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only include scope if scopes are provided
|
|
67
|
+
scope_claim = scopes.any? ? scopes.join(" ") : nil
|
|
68
|
+
|
|
69
|
+
new(
|
|
70
|
+
aud:,
|
|
71
|
+
exp:,
|
|
72
|
+
iat: Time.zone.now.to_i,
|
|
73
|
+
iss: TokenAuthority.config.rfc_9068_issuer_url,
|
|
74
|
+
jti: SecureRandom.uuid,
|
|
75
|
+
scope: scope_claim
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Decodes a JWT string into a RefreshToken instance.
|
|
80
|
+
#
|
|
81
|
+
# @param token [String] the JWT-encoded refresh token
|
|
82
|
+
#
|
|
83
|
+
# @return [TokenAuthority::RefreshToken] the decoded token instance
|
|
84
|
+
#
|
|
85
|
+
# @raise [JWT::DecodeError] if the token is malformed or signature is invalid
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# token = RefreshToken.from_token(jwt_string)
|
|
89
|
+
# jti = token.jti
|
|
90
|
+
def self.from_token(token)
|
|
91
|
+
new(TokenAuthority::JsonWebToken.decode(token))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Converts the token to a hash of JWT claims.
|
|
95
|
+
#
|
|
96
|
+
# Nil values are omitted from the hash to produce a minimal JWT payload.
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash] the JWT claims
|
|
99
|
+
def to_h
|
|
100
|
+
{aud:, exp:, iat:, iss:, jti:, scope:}.compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Encodes the token as a signed JWT string.
|
|
104
|
+
#
|
|
105
|
+
# @return [String] the JWT-encoded refresh token
|
|
106
|
+
def to_encoded_token
|
|
107
|
+
TokenAuthority::JsonWebToken.encode(to_h, exp)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Models a refresh token request
|
|
6
|
+
class RefreshTokenRequest
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
include ActiveModel::Validations
|
|
9
|
+
include TokenAuthority::Resourceable
|
|
10
|
+
include TokenAuthority::Scopeable
|
|
11
|
+
|
|
12
|
+
attr_accessor :token, :client_id
|
|
13
|
+
|
|
14
|
+
validate :token_authority_session_must_be_valid
|
|
15
|
+
validate :resources_must_be_valid
|
|
16
|
+
validate :scope_must_be_valid
|
|
17
|
+
|
|
18
|
+
# Returns the effective resources (requested or falls back to grant's resources)
|
|
19
|
+
def effective_resources
|
|
20
|
+
return resources if resources.any?
|
|
21
|
+
|
|
22
|
+
token_authority_authorization_grant&.resources || []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the effective scopes (requested or falls back to grant's scopes)
|
|
26
|
+
def effective_scopes
|
|
27
|
+
return scope if scope.present?
|
|
28
|
+
|
|
29
|
+
token_authority_authorization_grant&.scopes || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the session for use in refresh operation
|
|
33
|
+
def token_authority_session
|
|
34
|
+
return nil unless token
|
|
35
|
+
|
|
36
|
+
@token_authority_session ||= TokenAuthority::Session.find_by(refresh_token_jti: token.jti)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the resolved client_id (from param or from grant's client)
|
|
40
|
+
def resolved_client_id
|
|
41
|
+
client_id.presence || token_authority_session&.token_authority_authorization_grant&.resolved_client&.public_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def token_authority_session_must_be_valid
|
|
47
|
+
errors.add(:token, :blank) and return if token.blank?
|
|
48
|
+
|
|
49
|
+
errors.add(:token, :session_not_found) if token_authority_session.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def token_authority_authorization_grant
|
|
53
|
+
@token_authority_authorization_grant ||= token_authority_session&.token_authority_authorization_grant
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resources_must_be_valid
|
|
57
|
+
return unless token_authority_session_valid?
|
|
58
|
+
return if resources.empty?
|
|
59
|
+
|
|
60
|
+
# If resources are provided but feature is disabled, reject them
|
|
61
|
+
unless TokenAuthority.config.rfc_8707_enabled?
|
|
62
|
+
errors.add(:resources, :not_allowed)
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validate all resource URIs
|
|
67
|
+
unless valid_resource_uris?
|
|
68
|
+
errors.add(:resources, :invalid_uri)
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check against allowed resources list
|
|
73
|
+
unless allowed_resources?
|
|
74
|
+
errors.add(:resources, :not_allowed)
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check that requested resources are a subset of granted resources (downscoping)
|
|
79
|
+
granted_resources = token_authority_authorization_grant.resources
|
|
80
|
+
errors.add(:resources, :not_subset) unless resources_subset_of?(granted_resources)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def scope_must_be_valid
|
|
84
|
+
return unless token_authority_session_valid?
|
|
85
|
+
return if scope.blank?
|
|
86
|
+
|
|
87
|
+
# If scopes are provided but feature is disabled, reject them
|
|
88
|
+
unless TokenAuthority.config.scopes_enabled?
|
|
89
|
+
errors.add(:scope, :not_allowed)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Validate all scope tokens
|
|
94
|
+
unless valid_scope_tokens?
|
|
95
|
+
errors.add(:scope, :invalid)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check against allowed scopes list
|
|
100
|
+
unless allowed_scopes?
|
|
101
|
+
errors.add(:scope, :not_allowed)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check that requested scopes are a subset of granted scopes (downscoping)
|
|
106
|
+
granted_scopes = token_authority_authorization_grant.scopes || []
|
|
107
|
+
errors.add(:scope, :not_subset) unless scopes_subset_of?(granted_scopes)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def token_authority_session_valid?
|
|
111
|
+
return @token_authority_session_valid if defined?(@token_authority_session_valid)
|
|
112
|
+
|
|
113
|
+
@token_authority_session_valid = token_authority_session.present?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
# Represents an OAuth token session containing access and refresh tokens.
|
|
5
|
+
#
|
|
6
|
+
# Sessions track the lifecycle of token pairs from creation through expiration,
|
|
7
|
+
# refresh, or revocation. They implement refresh token rotation (each refresh
|
|
8
|
+
# creates a new session) and revocation detection to prevent replay attacks.
|
|
9
|
+
#
|
|
10
|
+
# The session stores JWT identifiers (jti claims) for both access and refresh
|
|
11
|
+
# tokens, but not the tokens themselves. This allows efficient revocation lookups
|
|
12
|
+
# without storing the full JWT payloads.
|
|
13
|
+
#
|
|
14
|
+
# Session status transitions:
|
|
15
|
+
# - created: Active session with valid tokens
|
|
16
|
+
# - refreshed: Old session that was replaced by a refresh operation
|
|
17
|
+
# - expired: Session that exceeded its lifetime (future use)
|
|
18
|
+
# - revoked: Session that was explicitly revoked or detected as compromised
|
|
19
|
+
#
|
|
20
|
+
# @example Refreshing a session
|
|
21
|
+
# new_session = session.refresh(
|
|
22
|
+
# token: refresh_token,
|
|
23
|
+
# client_id: client.public_id,
|
|
24
|
+
# resources: ["https://api.example.com"]
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# @example Revoking a session
|
|
28
|
+
# session.revoke_self_and_active_session(
|
|
29
|
+
# reason: "user_logout",
|
|
30
|
+
# request_id: "req-123"
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @since 0.2.0
|
|
34
|
+
class Session < ApplicationRecord
|
|
35
|
+
include TokenAuthority::SessionCreatable
|
|
36
|
+
include TokenAuthority::EventLogging
|
|
37
|
+
|
|
38
|
+
STATUS_ENUM_VALUES = {
|
|
39
|
+
created: "created",
|
|
40
|
+
expired: "expired",
|
|
41
|
+
refreshed: "refreshed",
|
|
42
|
+
revoked: "revoked"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
VALID_UUID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}/i
|
|
46
|
+
|
|
47
|
+
belongs_to :token_authority_authorization_grant, class_name: "TokenAuthority::AuthorizationGrant"
|
|
48
|
+
|
|
49
|
+
enum :status, STATUS_ENUM_VALUES, suffix: true
|
|
50
|
+
|
|
51
|
+
delegate :user_id, to: :token_authority_authorization_grant
|
|
52
|
+
|
|
53
|
+
validates :access_token_jti, presence: true, uniqueness: true, format: {with: VALID_UUID_REGEX}
|
|
54
|
+
validates :refresh_token_jti, presence: true, uniqueness: true, format: {with: VALID_UUID_REGEX}
|
|
55
|
+
|
|
56
|
+
# Refreshes the session by creating a new session with rotated tokens.
|
|
57
|
+
#
|
|
58
|
+
# This implements refresh token rotation as recommended by OAuth 2.1.
|
|
59
|
+
# The current session is marked as "refreshed" and a new session is created
|
|
60
|
+
# with new access and refresh tokens.
|
|
61
|
+
#
|
|
62
|
+
# Includes replay attack detection: if the session is not in "created" status
|
|
63
|
+
# or the client_id doesn't match, the refresh token has been stolen or reused.
|
|
64
|
+
# In this case, both the current session and any active session are revoked.
|
|
65
|
+
#
|
|
66
|
+
# @param token [TokenAuthority::RefreshToken] the refresh token to validate
|
|
67
|
+
# @param client_id [String] the client ID attempting the refresh
|
|
68
|
+
# @param resources [Array<String>] resource indicators for the new tokens
|
|
69
|
+
# @param scopes [Array<String>] scopes for the new tokens
|
|
70
|
+
#
|
|
71
|
+
# @return [TokenAuthority::Session] the newly created session
|
|
72
|
+
#
|
|
73
|
+
# @raise [TokenAuthority::ServerError] if the token JTI doesn't match
|
|
74
|
+
# @raise [TokenAuthority::InvalidGrantError] if the token is invalid
|
|
75
|
+
# @raise [TokenAuthority::RevokedSessionError] if replay attack is detected
|
|
76
|
+
def refresh(token:, client_id:, resources: [], scopes: [])
|
|
77
|
+
instrument("session.refresh") do
|
|
78
|
+
raise TokenAuthority::ServerError, I18n.t("token_authority.errors.mismatched_refresh_token") unless token.jti == refresh_token_jti
|
|
79
|
+
raise TokenAuthority::InvalidGrantError unless token.valid?
|
|
80
|
+
|
|
81
|
+
# Detect stolen refresh token and replay attacks, and then revoke current active token authority session
|
|
82
|
+
unless created_status? && client_id == token_authority_authorization_grant.resolved_client.public_id
|
|
83
|
+
session = token_authority_authorization_grant.active_token_authority_session || self
|
|
84
|
+
session.update(status: "revoked")
|
|
85
|
+
raise TokenAuthority::RevokedSessionError.new(
|
|
86
|
+
client_id:,
|
|
87
|
+
refreshed_session_id: id,
|
|
88
|
+
revoked_session_id: session.id,
|
|
89
|
+
user_id:
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
create_token_authority_session(grant: token_authority_authorization_grant, resources:, scopes:) do
|
|
94
|
+
update(status: "refreshed")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
rescue TokenAuthority::ServerError => error
|
|
98
|
+
raise TokenAuthority::ServerError, error.message
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Revokes this session and any active session for the same authorization grant.
|
|
102
|
+
#
|
|
103
|
+
# This ensures that revoking any token (access or refresh) invalidates the
|
|
104
|
+
# entire token family, preventing continued use after revocation. Emits a
|
|
105
|
+
# security event for audit logging.
|
|
106
|
+
#
|
|
107
|
+
# @param reason [String] the reason for revocation (default: "revocation_requested")
|
|
108
|
+
# @param request_id [String, nil] the request ID for correlation in logs
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
#
|
|
112
|
+
# @example
|
|
113
|
+
# session.revoke_self_and_active_session(
|
|
114
|
+
# reason: "user_logout",
|
|
115
|
+
# request_id: request.uuid
|
|
116
|
+
# )
|
|
117
|
+
def revoke_self_and_active_session(reason: "revocation_requested", request_id: nil)
|
|
118
|
+
instrument("session.revoke") do
|
|
119
|
+
related_session_ids = []
|
|
120
|
+
ActiveRecord::Base.transaction do
|
|
121
|
+
update(status: "revoked")
|
|
122
|
+
active_session = token_authority_authorization_grant.active_token_authority_session
|
|
123
|
+
if active_session && active_session.id != id
|
|
124
|
+
active_session.update(status: "revoked")
|
|
125
|
+
related_session_ids << active_session.id
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
notify_event("security.session.revoked",
|
|
130
|
+
request_id: request_id,
|
|
131
|
+
session_id: id,
|
|
132
|
+
client_id: token_authority_authorization_grant.resolved_client&.public_id,
|
|
133
|
+
reason: reason,
|
|
134
|
+
related_session_ids: related_session_ids)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Revokes the session associated with a token JTI.
|
|
139
|
+
#
|
|
140
|
+
# Searches for a session by either access token or refresh token JTI.
|
|
141
|
+
# This is the generic revocation method used when the token type is unknown.
|
|
142
|
+
#
|
|
143
|
+
# @param jti [String] the JWT identifier from the token
|
|
144
|
+
#
|
|
145
|
+
# @return [void]
|
|
146
|
+
#
|
|
147
|
+
# @note Returns silently if no session is found
|
|
148
|
+
def self.revoke_for_token(jti:)
|
|
149
|
+
# Must use find_by in this manner due to AR encryption
|
|
150
|
+
token_authority_session = find_by(access_token_jti: jti) || find_by(refresh_token_jti: jti)
|
|
151
|
+
execute_revocation(token_authority_session:)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Revokes the session associated with an access token JTI.
|
|
155
|
+
#
|
|
156
|
+
# More efficient than revoke_for_token when the token type is known.
|
|
157
|
+
#
|
|
158
|
+
# @param access_token_jti [String] the access token's JWT identifier
|
|
159
|
+
#
|
|
160
|
+
# @return [void]
|
|
161
|
+
def self.revoke_for_access_token(access_token_jti:)
|
|
162
|
+
token_authority_session = find_by(access_token_jti:)
|
|
163
|
+
execute_revocation(token_authority_session:)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Revokes the session associated with a refresh token JTI.
|
|
167
|
+
#
|
|
168
|
+
# More efficient than revoke_for_token when the token type is known.
|
|
169
|
+
#
|
|
170
|
+
# @param refresh_token_jti [String] the refresh token's JWT identifier
|
|
171
|
+
#
|
|
172
|
+
# @return [void]
|
|
173
|
+
def self.revoke_for_refresh_token(refresh_token_jti:)
|
|
174
|
+
token_authority_session = find_by(refresh_token_jti:)
|
|
175
|
+
execute_revocation(token_authority_session:)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class << self
|
|
179
|
+
# Internal helper to execute the revocation of a session.
|
|
180
|
+
#
|
|
181
|
+
# @param token_authority_session [TokenAuthority::Session, nil] the session to revoke
|
|
182
|
+
#
|
|
183
|
+
# @return [void]
|
|
184
|
+
#
|
|
185
|
+
# @api private
|
|
186
|
+
def execute_revocation(token_authority_session:)
|
|
187
|
+
return if token_authority_session.blank?
|
|
188
|
+
|
|
189
|
+
token_authority_session.revoke_self_and_active_session
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Value object for parsing and validating software statements (signed JWT metadata)
|
|
6
|
+
class SoftwareStatement
|
|
7
|
+
STANDARD_CLAIMS = %i[
|
|
8
|
+
redirect_uris
|
|
9
|
+
token_endpoint_auth_method
|
|
10
|
+
grant_types
|
|
11
|
+
response_types
|
|
12
|
+
client_name
|
|
13
|
+
client_uri
|
|
14
|
+
logo_uri
|
|
15
|
+
scope
|
|
16
|
+
contacts
|
|
17
|
+
tos_uri
|
|
18
|
+
policy_uri
|
|
19
|
+
jwks_uri
|
|
20
|
+
jwks
|
|
21
|
+
software_id
|
|
22
|
+
software_version
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :raw_jwt, :payload, :header
|
|
26
|
+
|
|
27
|
+
def initialize(raw_jwt:, payload:, header:, verified: false)
|
|
28
|
+
@raw_jwt = raw_jwt
|
|
29
|
+
@payload = payload.with_indifferent_access
|
|
30
|
+
@header = header.with_indifferent_access
|
|
31
|
+
@verified = verified
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def decode(jwt)
|
|
36
|
+
payload, header = JWT.decode(jwt, nil, false)
|
|
37
|
+
new(raw_jwt: jwt, payload: payload, header: header, verified: false)
|
|
38
|
+
rescue JWT::DecodeError => e
|
|
39
|
+
raise TokenAuthority::InvalidSoftwareStatementError, e.message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def decode_and_verify(jwt, jwks:)
|
|
43
|
+
jwk_set = jwks.is_a?(JWT::JWK::Set) ? jwks : JWT::JWK::Set.new(jwks)
|
|
44
|
+
algorithms = jwk_set.map { |key| key[:alg] }.compact.uniq
|
|
45
|
+
algorithms = ["RS256"] if algorithms.empty?
|
|
46
|
+
|
|
47
|
+
payload, header = JWT.decode(jwt, nil, true, {algorithms: algorithms, jwks: jwk_set})
|
|
48
|
+
new(raw_jwt: jwt, payload: payload, header: header, verified: true)
|
|
49
|
+
rescue JWT::DecodeError => e
|
|
50
|
+
raise TokenAuthority::InvalidSoftwareStatementError, e.message
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
STANDARD_CLAIMS.each do |claim|
|
|
55
|
+
define_method(claim) { payload[claim] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def claims
|
|
59
|
+
payload.slice(*STANDARD_CLAIMS)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def trusted?
|
|
63
|
+
@verified
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
claims
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<h2><%= t('token_authority.authorization_grants.new.title') %></h2>
|
|
3
|
+
<p><%= t('token_authority.authorization_grants.new.lede') %></p>
|
|
4
|
+
<p><strong><%= client_name %></strong></p>
|
|
5
|
+
<% if resources.any? %>
|
|
6
|
+
<p><%= t('token_authority.authorization_grants.new.resources_heading') %></p>
|
|
7
|
+
<ul>
|
|
8
|
+
<% resources.each do |resource| %>
|
|
9
|
+
<li><%= resource_display_name(resource) %></li>
|
|
10
|
+
<% end %>
|
|
11
|
+
</ul>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% if scopes.any? %>
|
|
14
|
+
<p><%= t('token_authority.authorization_grants.new.scopes_heading') %></p>
|
|
15
|
+
<ul>
|
|
16
|
+
<% scopes.each do |scope| %>
|
|
17
|
+
<li><%= scope_display_name(scope) %></li>
|
|
18
|
+
<% end %>
|
|
19
|
+
</ul>
|
|
20
|
+
<% end %>
|
|
21
|
+
<div>
|
|
22
|
+
<%= button_to t('token_authority.authorization_grants.new.reject_cta'), authorization_grants_path, params: { approve: false } %>
|
|
23
|
+
<%= button_to t('token_authority.authorization_grants.new.approve_cta'), authorization_grants_path, params: { approve: true } %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|