authproto 0.3.0.beta1
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/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- metadata +200 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
require_relative '../errors'
|
|
6
|
+
require_relative 'key_manager'
|
|
7
|
+
require_relative 'nonce_manager'
|
|
8
|
+
require_relative 'proof_generator'
|
|
9
|
+
|
|
10
|
+
module AuthProto
|
|
11
|
+
|
|
12
|
+
#
|
|
13
|
+
# Key management, per-request proofs and server-origin nonces for DPoP authentication.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
module DPoP
|
|
17
|
+
|
|
18
|
+
# High-level client for managing DPoP operations. Integrates key management,
|
|
19
|
+
# proof generation, and nonce tracking to provide a complete DPoP client
|
|
20
|
+
# implementation according to RFC 9449.
|
|
21
|
+
#
|
|
22
|
+
# This client handles:
|
|
23
|
+
# - Key management for signing proofs
|
|
24
|
+
# - Proof generation for HTTP requests
|
|
25
|
+
# - Nonce tracking across servers
|
|
26
|
+
# - Header construction for requests
|
|
27
|
+
# - Response processing for nonce updates
|
|
28
|
+
#
|
|
29
|
+
|
|
30
|
+
class Client
|
|
31
|
+
|
|
32
|
+
# Error raised for DPoP client operations
|
|
33
|
+
class Error < AuthProto::Error; end
|
|
34
|
+
|
|
35
|
+
# Key manager used to sign proofs.
|
|
36
|
+
# @return [KeyManager]
|
|
37
|
+
attr_reader :key_manager
|
|
38
|
+
|
|
39
|
+
# Generator for per-request DPoP JWTs.
|
|
40
|
+
# @return [ProofGenerator]
|
|
41
|
+
attr_reader :proof_generator
|
|
42
|
+
|
|
43
|
+
# Persistent nonce store, indexed by server origin.
|
|
44
|
+
# @return [NonceManager]
|
|
45
|
+
attr_reader :nonce_manager
|
|
46
|
+
|
|
47
|
+
# Creates DPoP signing, proof and nonce managers.
|
|
48
|
+
#
|
|
49
|
+
# @param key_manager [KeyManager, nil] existing key manager, or `nil` to generate a keypair
|
|
50
|
+
# @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds, or the nonce manager's default
|
|
51
|
+
# @raise [KeyManager::KeyError] if key generation fails
|
|
52
|
+
# @raise [ProofGenerator::ProofError] if the key manager is invalid
|
|
53
|
+
# @raise [ArgumentError] if the encryption master key is not valid Base64
|
|
54
|
+
#
|
|
55
|
+
def initialize(key_manager: nil, nonce_ttl: nil)
|
|
56
|
+
@key_manager = key_manager || KeyManager.new
|
|
57
|
+
@nonce_manager = NonceManager.new(ttl: nonce_ttl)
|
|
58
|
+
@proof_generator = ProofGenerator.new(@key_manager)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generates a fresh DPoP proof using a supplied or stored nonce for the origin.
|
|
62
|
+
#
|
|
63
|
+
# @param http_method [String] HTTP method, converted to uppercase
|
|
64
|
+
# @param http_uri [String] absolute HTTP(S) URL; callers should omit the query and fragment
|
|
65
|
+
# @param access_token [String, nil] token to bind with an `ath` claim
|
|
66
|
+
# @param nonce [String, nil] explicit nonce; `nil` looks up the origin's stored nonce
|
|
67
|
+
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
68
|
+
# @raise [Error] if URI parsing, nonce lookup or signing fails
|
|
69
|
+
#
|
|
70
|
+
def generate_proof(http_method:, http_uri:, access_token: nil, nonce: nil)
|
|
71
|
+
uri = URI(http_uri)
|
|
72
|
+
server_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
|
|
73
|
+
|
|
74
|
+
# Use provided nonce or get one from the manager
|
|
75
|
+
nonce ||= @nonce_manager.get(server_url)
|
|
76
|
+
|
|
77
|
+
@proof_generator.generate(
|
|
78
|
+
http_method: http_method,
|
|
79
|
+
http_uri: http_uri,
|
|
80
|
+
nonce: nonce,
|
|
81
|
+
access_token: access_token
|
|
82
|
+
)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
raise Error, "Failed to generate proof: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Stores a `DPoP-Nonce` response header for future requests to the same origin.
|
|
88
|
+
# Header names are compared case-insensitively. Missing headers/nonces are ignored.
|
|
89
|
+
#
|
|
90
|
+
# @param response_headers [Hash{String => String}, nil] response headers, or `nil`
|
|
91
|
+
# @param server_url [String] request URL whose origin owns the nonce
|
|
92
|
+
# @return [void]
|
|
93
|
+
# @raise [Error] if nonce validation or persistence fails
|
|
94
|
+
#
|
|
95
|
+
def process_response(response_headers, server_url)
|
|
96
|
+
return unless response_headers
|
|
97
|
+
|
|
98
|
+
# Look for DPoP-Nonce header (case insensitive)
|
|
99
|
+
nonce = response_headers.find { |k, _| k.downcase == "dpop-nonce" }&.last
|
|
100
|
+
return unless nonce
|
|
101
|
+
|
|
102
|
+
# Store new nonce for future requests
|
|
103
|
+
@nonce_manager.update(nonce: nonce, server_url: server_url)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
raise Error, "Failed to process response: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Wraps a proof in an HTTP header hash.
|
|
109
|
+
#
|
|
110
|
+
# @param proof [String] signed DPoP JWT
|
|
111
|
+
# @return [Hash{String => String}] a single `DPoP` header
|
|
112
|
+
#
|
|
113
|
+
def request_headers(proof)
|
|
114
|
+
{
|
|
115
|
+
"DPoP" => proof
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Exports the current public signing key.
|
|
120
|
+
#
|
|
121
|
+
# @return [Hash{String => Object}] public JWK
|
|
122
|
+
# @raise [KeyManager::KeyError] if public-key export fails
|
|
123
|
+
#
|
|
124
|
+
def public_key
|
|
125
|
+
@key_manager.public_jwk
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Exports the current DPoP key for persistence or inspection.
|
|
129
|
+
#
|
|
130
|
+
# @param include_private [Boolean] include the private key needed to restore the same identity
|
|
131
|
+
# @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
|
|
132
|
+
# @raise [KeyManager::KeyError] if key export fails
|
|
133
|
+
#
|
|
134
|
+
def export_key(include_private: false)
|
|
135
|
+
@key_manager.to_jwk(include_private: include_private)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def extract_nonce(headers)
|
|
142
|
+
# Headers can be hash with string or symbol keys, or http headers object
|
|
143
|
+
headers = headers.to_h if headers.respond_to?(:to_h)
|
|
144
|
+
|
|
145
|
+
# Try different common header key formats
|
|
146
|
+
nonce = headers["DPoP-Nonce"] || headers["dpop-nonce"] || headers[:dpop_nonce]
|
|
147
|
+
|
|
148
|
+
nonce&.strip
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def origin_for_uri(uri)
|
|
152
|
+
port = uri.port
|
|
153
|
+
port = nil if (uri.scheme == "https" && port == 443) || (uri.scheme == "http" && port == 80)
|
|
154
|
+
|
|
155
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
156
|
+
origin = "#{origin}:#{port}" if port
|
|
157
|
+
origin
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'jose'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
require_relative '../errors'
|
|
9
|
+
|
|
10
|
+
module AuthProto
|
|
11
|
+
module DPoP
|
|
12
|
+
|
|
13
|
+
#
|
|
14
|
+
# Manages ES256 keypair generation and storage for DPoP proofs.
|
|
15
|
+
# Provides functionality to generate new keys and store them securely.
|
|
16
|
+
# Uses JOSE for cryptographic operations and key format handling.
|
|
17
|
+
#
|
|
18
|
+
|
|
19
|
+
class KeyManager
|
|
20
|
+
|
|
21
|
+
# Error raised when key operations fail
|
|
22
|
+
class KeyError < AuthProto::Error; end
|
|
23
|
+
|
|
24
|
+
# Elliptic curve used for DPoP signing keys.
|
|
25
|
+
# @return [String]
|
|
26
|
+
CURVE = "P-256"
|
|
27
|
+
|
|
28
|
+
# JWS signing algorithm used for DPoP proofs.
|
|
29
|
+
# @return [String]
|
|
30
|
+
ALGORITHM = "ES256"
|
|
31
|
+
|
|
32
|
+
# Private EC P-256 keypair used for signing.
|
|
33
|
+
# @return [JOSE::JWK]
|
|
34
|
+
attr_reader :keypair
|
|
35
|
+
|
|
36
|
+
# Creates a new KeyManager instance with an optional existing keypair
|
|
37
|
+
#
|
|
38
|
+
# @param keypair [JOSE::JWK, nil] Optional existing keypair to use
|
|
39
|
+
# @raise [KeyError] if the provided keypair is invalid
|
|
40
|
+
#
|
|
41
|
+
def initialize(keypair = nil)
|
|
42
|
+
@keypair = keypair || generate_keypair
|
|
43
|
+
validate_keypair
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Generates a new ES256 keypair for DPoP usage
|
|
47
|
+
#
|
|
48
|
+
# @return [JOSE::JWK] The newly generated keypair
|
|
49
|
+
# @raise [KeyError] if key generation fails
|
|
50
|
+
#
|
|
51
|
+
def generate_keypair
|
|
52
|
+
# Generate base keypair
|
|
53
|
+
base_key = JOSE::JWK.generate_key([:ec, CURVE])
|
|
54
|
+
base_map = base_key.to_map
|
|
55
|
+
|
|
56
|
+
# Create new map with all required properties
|
|
57
|
+
key_map = {
|
|
58
|
+
"kty" => base_map["kty"],
|
|
59
|
+
"crv" => base_map["crv"],
|
|
60
|
+
"x" => base_map["x"],
|
|
61
|
+
"y" => base_map["y"],
|
|
62
|
+
"d" => base_map["d"],
|
|
63
|
+
"use" => "sig",
|
|
64
|
+
"kid" => generate_kid(base_map)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Create new JWK with all properties
|
|
68
|
+
JOSE::JWK.from_map(key_map)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
raise KeyError, "Failed to generate keypair: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Exports the public key with `use: sig` and a key ID.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{String => Object}] public JWK without private key material
|
|
76
|
+
# @raise [KeyError] if public-key export fails
|
|
77
|
+
#
|
|
78
|
+
def public_jwk
|
|
79
|
+
jwk = @keypair.to_public.to_map.to_h
|
|
80
|
+
# If somehow the properties aren't set, add them
|
|
81
|
+
jwk["use"] ||= "sig"
|
|
82
|
+
jwk["kid"] ||= generate_kid(jwk)
|
|
83
|
+
jwk
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
raise KeyError, "Failed to export public key: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Signs bytes with this keypair and returns a compact JWS.
|
|
89
|
+
#
|
|
90
|
+
# @param data [String] payload bytes to sign
|
|
91
|
+
# @return [JOSE::SignedBinary] compact JWS (a string subclass)
|
|
92
|
+
# @raise [KeyError] if signing fails
|
|
93
|
+
#
|
|
94
|
+
def sign(data)
|
|
95
|
+
@keypair.sign(data).compact
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
raise KeyError, "Failed to sign data: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Signs a JSON payload with the supplied protected JWS header and ES256.
|
|
101
|
+
# Hash keys are recursively converted to strings. The algorithm is forced to `ES256`.
|
|
102
|
+
#
|
|
103
|
+
# @param header [Hash{String, Symbol => Object}] protected JOSE header fields
|
|
104
|
+
# @param payload [Hash{String, Symbol => Object}] JWT claims to JSON-encode
|
|
105
|
+
# @return [JOSE::SignedBinary] compact JWS (a string subclass)
|
|
106
|
+
# @raise [KeyError] if encoding or signing fails
|
|
107
|
+
#
|
|
108
|
+
def sign_segments(header, payload)
|
|
109
|
+
# Deep transform all keys to strings to avoid symbol comparison issues
|
|
110
|
+
header = deep_stringify_keys(header)
|
|
111
|
+
payload = deep_stringify_keys(payload)
|
|
112
|
+
|
|
113
|
+
# Configure JOSE to use ES256 for signing
|
|
114
|
+
signing_config = { "alg" => "ES256" }
|
|
115
|
+
|
|
116
|
+
# Merge our header with JOSE's required fields
|
|
117
|
+
full_header = header.merge(signing_config)
|
|
118
|
+
|
|
119
|
+
# Convert payload to JSON string before signing
|
|
120
|
+
payload_json = JSON.generate(payload)
|
|
121
|
+
|
|
122
|
+
# Create the JWS with our header and payload
|
|
123
|
+
jws = @keypair.sign(payload_json, full_header)
|
|
124
|
+
|
|
125
|
+
# Get the compact serialization
|
|
126
|
+
jws.compact
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
raise KeyError, "Failed to sign segments: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Copies hashes and arrays recursively, converting hash keys to strings.
|
|
132
|
+
#
|
|
133
|
+
# @param obj [Object] input structure or scalar value
|
|
134
|
+
# @return [Object] transformed hash/array, or the original scalar
|
|
135
|
+
#
|
|
136
|
+
def deep_stringify_keys(obj)
|
|
137
|
+
case obj
|
|
138
|
+
when Hash
|
|
139
|
+
obj.each_with_object({}) do |(k, v), hash|
|
|
140
|
+
hash[k.to_s] = deep_stringify_keys(v)
|
|
141
|
+
end
|
|
142
|
+
when Array
|
|
143
|
+
obj.map { |v| deep_stringify_keys(v) }
|
|
144
|
+
else
|
|
145
|
+
obj
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Verifies a compact JWS using this keypair.
|
|
150
|
+
#
|
|
151
|
+
# @param signed_jws [String, JOSE::SignedBinary] complete signed JWS
|
|
152
|
+
# @return [Boolean] whether the signature is valid
|
|
153
|
+
# @raise [KeyError] if decoding or verification raises an error
|
|
154
|
+
#
|
|
155
|
+
def verify(signed_jws)
|
|
156
|
+
verified, payload, = @keypair.verify(signed_jws)
|
|
157
|
+
verified
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
raise KeyError, "Failed to verify signature: #{e.message}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Exports this keypair as a JOSE map.
|
|
163
|
+
#
|
|
164
|
+
# @param include_private [Boolean] include secret `d` key material when `true`
|
|
165
|
+
# @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
|
|
166
|
+
# @raise [KeyError] if export fails
|
|
167
|
+
#
|
|
168
|
+
def to_jwk(include_private: false)
|
|
169
|
+
key = include_private ? @keypair : @keypair.to_public
|
|
170
|
+
key.to_map
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
raise KeyError, "Failed to export key: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Imports an EC P-256 JWK and validates that it can sign and verify.
|
|
176
|
+
#
|
|
177
|
+
# @param jwk [Hash{String => Object}, JOSE::Map] JWK including private key material
|
|
178
|
+
# @return [KeyManager] imported key manager
|
|
179
|
+
# @raise [KeyError] if import or key validation fails
|
|
180
|
+
#
|
|
181
|
+
def self.from_jwk(jwk)
|
|
182
|
+
keypair = JOSE::JWK.from_map(jwk)
|
|
183
|
+
new(keypair)
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
raise KeyError, "Failed to import key: #{e.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def generate_kid(jwk)
|
|
191
|
+
# Generate a key ID based on the key's components
|
|
192
|
+
components = [
|
|
193
|
+
jwk["kty"],
|
|
194
|
+
jwk["crv"],
|
|
195
|
+
jwk["x"],
|
|
196
|
+
jwk["y"]
|
|
197
|
+
].join(":")
|
|
198
|
+
|
|
199
|
+
# Create a SHA-256 hash and take first 8 bytes
|
|
200
|
+
digest = OpenSSL::Digest::SHA256.digest(components)
|
|
201
|
+
Base64.urlsafe_encode64(digest[0..7], padding: false)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Validates that the keypair meets DPoP requirements
|
|
205
|
+
# @raise [KeyError] if validation fails
|
|
206
|
+
def validate_keypair
|
|
207
|
+
# Check that we have a valid EC key
|
|
208
|
+
raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
|
|
209
|
+
|
|
210
|
+
# Verify the curve
|
|
211
|
+
curve = @keypair.to_map["crv"]
|
|
212
|
+
raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
|
|
213
|
+
|
|
214
|
+
# Verify we can perform basic operations
|
|
215
|
+
test_data = "test"
|
|
216
|
+
signed = sign(test_data)
|
|
217
|
+
raise KeyError, "Key validation failed: signature verification error" unless verify(signed)
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
raise KeyError, "Key validation failed: #{e.message}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
require_relative '../configuration'
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
require_relative '../serialization/stored_nonce'
|
|
8
|
+
require_relative '../storage/key_builder'
|
|
9
|
+
require_relative 'stored_nonce'
|
|
10
|
+
|
|
11
|
+
module AuthProto
|
|
12
|
+
module DPoP
|
|
13
|
+
|
|
14
|
+
#
|
|
15
|
+
# Manages DPoP nonces provided by servers during the OAuth flow.
|
|
16
|
+
# Tracks separate nonces for each server using persistent storage.
|
|
17
|
+
# Thread-safe to handle concurrent requests.
|
|
18
|
+
#
|
|
19
|
+
|
|
20
|
+
class NonceManager
|
|
21
|
+
|
|
22
|
+
# Error for nonce-related issues
|
|
23
|
+
class NonceError < AuthProto::Error; end
|
|
24
|
+
|
|
25
|
+
# Default stored nonce lifetime in seconds.
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
DEFAULT_TTL = 300 # 5 minutes
|
|
28
|
+
|
|
29
|
+
# Creates a nonce manager using the global storage backend.
|
|
30
|
+
#
|
|
31
|
+
# @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {DEFAULT_TTL}
|
|
32
|
+
# @raise [ArgumentError] if the master encryption key is not valid Base64
|
|
33
|
+
#
|
|
34
|
+
def initialize(ttl: nil)
|
|
35
|
+
@ttl = ttl || DEFAULT_TTL
|
|
36
|
+
@serializer = Serialization::StoredNonce.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stores a nonce for the URL's origin, replacing any previous value.
|
|
40
|
+
#
|
|
41
|
+
# @param nonce [String] nonempty server-provided nonce
|
|
42
|
+
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [NonceError] if inputs are invalid or storage reports failure
|
|
45
|
+
# @raise [Storage::StorageError] if backend validation or storage fails
|
|
46
|
+
#
|
|
47
|
+
def update(nonce:, server_url:)
|
|
48
|
+
validate_inputs(nonce, server_url)
|
|
49
|
+
origin = normalize_server_url(server_url)
|
|
50
|
+
|
|
51
|
+
stored_nonce = StoredNonce.new(nonce, origin)
|
|
52
|
+
serialized = @serializer.serialize(stored_nonce)
|
|
53
|
+
|
|
54
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
|
55
|
+
return if AuthProto.storage.set(key, serialized, ttl: @ttl)
|
|
56
|
+
|
|
57
|
+
raise NonceError, "Failed to store nonce"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Retrieves the current nonce for a URL's origin.
|
|
61
|
+
#
|
|
62
|
+
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
63
|
+
# @return [String, nil] stored nonce, or `nil` when missing/expired according to storage TTL
|
|
64
|
+
# @raise [NonceError] if the URL is invalid or serialized data cannot be read
|
|
65
|
+
# @raise [Storage::StorageError] if storage retrieval fails
|
|
66
|
+
#
|
|
67
|
+
def get(server_url)
|
|
68
|
+
validate_server_url(server_url)
|
|
69
|
+
origin = normalize_server_url(server_url)
|
|
70
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
|
71
|
+
|
|
72
|
+
stored = AuthProto.storage.get(key)
|
|
73
|
+
return nil unless stored
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
stored_nonce = @serializer.deserialize(stored)
|
|
77
|
+
stored_nonce.value
|
|
78
|
+
rescue Serialization::Error => e
|
|
79
|
+
raise NonceError, "Failed to deserialize nonce: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Removes the stored nonce for a URL's origin.
|
|
84
|
+
#
|
|
85
|
+
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
86
|
+
# @return [Boolean] backend deletion status
|
|
87
|
+
# @raise [NonceError] if the URL is invalid
|
|
88
|
+
# @raise [Storage::StorageError] if deletion fails
|
|
89
|
+
#
|
|
90
|
+
def clear(server_url)
|
|
91
|
+
validate_server_url(server_url)
|
|
92
|
+
origin = normalize_server_url(server_url)
|
|
93
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
|
94
|
+
AuthProto.storage.delete(key)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Checks for an unexpired nonce record in storage for a URL's origin.
|
|
98
|
+
#
|
|
99
|
+
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
100
|
+
# @return [Boolean] whether the backend reports a nonce record present
|
|
101
|
+
# @raise [NonceError] if the URL is invalid
|
|
102
|
+
# @raise [Storage::StorageError] if the existence check fails
|
|
103
|
+
#
|
|
104
|
+
def valid_nonce?(server_url)
|
|
105
|
+
validate_server_url(server_url)
|
|
106
|
+
origin = normalize_server_url(server_url)
|
|
107
|
+
key = Storage::KeyBuilder.nonce_key(origin)
|
|
108
|
+
AuthProto.storage.exists?(key)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def normalize_server_url(url)
|
|
114
|
+
uri = URI(url)
|
|
115
|
+
port = uri.port
|
|
116
|
+
port = nil if (uri.scheme == "https" && port == 443) ||
|
|
117
|
+
(uri.scheme == "http" && port == 80)
|
|
118
|
+
|
|
119
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
120
|
+
origin = "#{origin}:#{port}" if port
|
|
121
|
+
origin
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_inputs(nonce, server_url)
|
|
125
|
+
raise NonceError, "nonce is required" if nonce.nil? || nonce.empty?
|
|
126
|
+
|
|
127
|
+
validate_server_url(server_url)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_server_url(server_url)
|
|
131
|
+
raise NonceError, "server_url is required" if server_url.nil? || server_url.empty?
|
|
132
|
+
|
|
133
|
+
uri = URI(server_url)
|
|
134
|
+
raise NonceError, "server_url must be HTTP(S)" unless uri.is_a?(URI::HTTP)
|
|
135
|
+
|
|
136
|
+
# Allow HTTP for localhost only
|
|
137
|
+
if uri.host != "localhost" && uri.scheme != "https"
|
|
138
|
+
raise NonceError, "server_url must be HTTPS (except for localhost)"
|
|
139
|
+
end
|
|
140
|
+
rescue URI::InvalidURIError => e
|
|
141
|
+
raise NonceError, "invalid server_url: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
require_relative '../errors'
|
|
9
|
+
require_relative 'key_manager'
|
|
10
|
+
|
|
11
|
+
module AuthProto
|
|
12
|
+
module DPoP
|
|
13
|
+
|
|
14
|
+
#
|
|
15
|
+
# Creates and manages DPoP proof JWTs according to RFC 9449.
|
|
16
|
+
# DPoP proofs are used to prove possession of a key when making
|
|
17
|
+
# HTTP requests. Each proof is a JWT that includes details about
|
|
18
|
+
# the request and is signed by the DPoP key.
|
|
19
|
+
#
|
|
20
|
+
|
|
21
|
+
class ProofGenerator
|
|
22
|
+
|
|
23
|
+
# Error raised for proof generation/validation issues
|
|
24
|
+
class ProofError < AuthProto::Error; end
|
|
25
|
+
|
|
26
|
+
# Key manager used to sign proofs.
|
|
27
|
+
# @return [KeyManager]
|
|
28
|
+
attr_reader :key_manager
|
|
29
|
+
|
|
30
|
+
# Creates a new ProofGenerator instance
|
|
31
|
+
#
|
|
32
|
+
# @param key_manager [KeyManager] Key manager to use for signing proofs
|
|
33
|
+
# @raise [ProofError] if key_manager is invalid
|
|
34
|
+
#
|
|
35
|
+
def initialize(key_manager)
|
|
36
|
+
raise ProofError, "key_manager is required" unless key_manager
|
|
37
|
+
raise ProofError, "invalid key_manager type" unless key_manager.is_a?(KeyManager)
|
|
38
|
+
|
|
39
|
+
@key_manager = key_manager
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Builds and signs a fresh DPoP JWT with `jti`, `htm`, `htu` and `iat` claims.
|
|
43
|
+
#
|
|
44
|
+
# A nonce and access-token hash are added when requested. The URI normalizer removes
|
|
45
|
+
# fragments and default ports; callers must remove query strings before calling.
|
|
46
|
+
#
|
|
47
|
+
# @param http_method [String] nonempty HTTP method, converted to uppercase
|
|
48
|
+
# @param http_uri [String] absolute HTTP(S) request URL without a query string
|
|
49
|
+
# @param nonce [String, nil] server-provided nonce to include
|
|
50
|
+
# @param access_token [String, nil] access token to bind to the proof
|
|
51
|
+
# @param ath [Boolean, nil] include an access-token hash; `nil` enables it when a token is supplied
|
|
52
|
+
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
53
|
+
# @raise [ProofError] if input validation or proof generation fails
|
|
54
|
+
#
|
|
55
|
+
def generate(http_method:, http_uri:, nonce: nil, access_token: nil, ath: nil)
|
|
56
|
+
validate_inputs(http_method, http_uri)
|
|
57
|
+
ath = !access_token.nil? if ath.nil?
|
|
58
|
+
|
|
59
|
+
header = build_header
|
|
60
|
+
payload = build_payload(
|
|
61
|
+
http_method: http_method,
|
|
62
|
+
http_uri: http_uri,
|
|
63
|
+
nonce: nonce,
|
|
64
|
+
access_token: access_token,
|
|
65
|
+
include_ath: ath
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
key_manager.sign_segments(header, payload)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
raise ProofError, "Failed to generate proof: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_inputs(http_method, http_uri)
|
|
76
|
+
raise ProofError, "http_method is required" if http_method.nil? || http_method.empty?
|
|
77
|
+
raise ProofError, "http_uri is required" if http_uri.nil? || http_uri.empty?
|
|
78
|
+
|
|
79
|
+
uri = URI(http_uri)
|
|
80
|
+
raise ProofError, "invalid http_uri" unless uri.is_a?(URI::HTTP)
|
|
81
|
+
rescue URI::InvalidURIError => e
|
|
82
|
+
raise ProofError, "invalid http_uri: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_header
|
|
86
|
+
{
|
|
87
|
+
typ: "dpop+jwt",
|
|
88
|
+
alg: "ES256",
|
|
89
|
+
jwk: key_manager.public_jwk.to_h
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_payload(http_method:, http_uri:, nonce: nil, access_token: nil, include_ath: nil)
|
|
94
|
+
payload = {
|
|
95
|
+
"jti" => SecureRandom.uuid,
|
|
96
|
+
"htm" => http_method.upcase,
|
|
97
|
+
"htu" => normalize_uri(http_uri),
|
|
98
|
+
"iat" => Time.now.to_i
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Add the nonce if provided
|
|
102
|
+
payload["nonce"] = nonce if nonce
|
|
103
|
+
|
|
104
|
+
# Add access token hash if needed
|
|
105
|
+
payload["ath"] = generate_access_token_hash(access_token) if access_token && include_ath
|
|
106
|
+
|
|
107
|
+
payload
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_uri(uri)
|
|
111
|
+
uri = URI(uri)
|
|
112
|
+
# Remove default ports
|
|
113
|
+
uri.port = nil if (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
|
|
114
|
+
uri.fragment = nil
|
|
115
|
+
uri.to_s
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def generate_access_token_hash(access_token)
|
|
119
|
+
digest = OpenSSL::Digest::SHA256.digest(access_token)
|
|
120
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuthProto
|
|
4
|
+
module DPoP
|
|
5
|
+
|
|
6
|
+
#
|
|
7
|
+
# Represents a stored nonce with its server URL
|
|
8
|
+
#
|
|
9
|
+
|
|
10
|
+
class StoredNonce
|
|
11
|
+
|
|
12
|
+
# Nonce value received from the server.
|
|
13
|
+
# @return [String]
|
|
14
|
+
attr_reader :value
|
|
15
|
+
|
|
16
|
+
# Server origin associated with the nonce.
|
|
17
|
+
# @return [String]
|
|
18
|
+
attr_reader :server_url
|
|
19
|
+
|
|
20
|
+
# Creation timestamp; defaults to integer Unix seconds.
|
|
21
|
+
# @return [Integer, Time]
|
|
22
|
+
attr_reader :timestamp
|
|
23
|
+
|
|
24
|
+
# Creates a nonce record without validation.
|
|
25
|
+
#
|
|
26
|
+
# @param value [String] server-provided nonce
|
|
27
|
+
# @param server_url [String] server origin URL
|
|
28
|
+
# @param timestamp [Integer, Time, nil] timestamp to retain, or `nil` for current Unix seconds
|
|
29
|
+
#
|
|
30
|
+
def initialize(value, server_url, timestamp: nil)
|
|
31
|
+
@value = value
|
|
32
|
+
@server_url = server_url
|
|
33
|
+
@timestamp = timestamp || Time.now.to_i
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|