authproto 0.3.0.beta1 → 0.3.0.beta2
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/.yardopts +1 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +48 -7
- data/README.md +20 -0
- data/examples/confidential_client/.gitignore +2 -1
- data/examples/confidential_client/Gemfile.lock +5 -1
- data/examples/confidential_client/README.md +2 -2
- data/examples/confidential_client/app.rb +17 -17
- data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
- data/examples/confidential_client/scripts/generate_keys.rb +15 -9
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/localhost.rb +8 -25
- data/examples/minisky_console/production.rb +1 -1
- data/lib/authproto/client.rb +150 -201
- data/lib/authproto/dpop/client.rb +12 -60
- data/lib/authproto/dpop/key_manager.rb +17 -17
- data/lib/authproto/dpop/nonce_manager.rb +5 -19
- data/lib/authproto/dpop/proof_generator.rb +27 -23
- data/lib/authproto/errors.rb +1 -1
- data/lib/authproto/http_client.rb +17 -31
- data/lib/authproto/localhost_server.rb +15 -6
- data/lib/authproto/metadata/authorization_server.rb +358 -0
- data/lib/authproto/metadata/client_metadata.rb +574 -0
- data/lib/authproto/metadata/resource_server.rb +137 -0
- data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
- data/lib/authproto/minisky.rb +8 -10
- data/lib/authproto/par/client.rb +51 -55
- data/lib/authproto/par/client_assertion.rb +58 -15
- data/lib/authproto/par/request.rb +58 -140
- data/lib/authproto/par/response.rb +9 -10
- data/lib/authproto/par.rb +3 -5
- data/lib/authproto/pkce.rb +29 -106
- data/lib/authproto/serialization/session.rb +11 -11
- data/lib/authproto/serialization/token_set.rb +6 -6
- data/lib/authproto/state/session.rb +27 -31
- data/lib/authproto/state/session_manager.rb +2 -2
- data/lib/authproto/state/token_set.rb +4 -4
- data/lib/authproto/state.rb +3 -3
- data/lib/authproto/token/refresh.rb +35 -43
- data/lib/authproto/validated_uri.rb +57 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +1 -0
- data/misc/yard_attributes.rb +34 -0
- data/sig/authproto/client.rbs +9 -10
- data/sig/authproto/dpop/client.rbs +1 -8
- data/sig/authproto/dpop/nonce_manager.rbs +0 -1
- data/sig/authproto/dpop/proof_generator.rbs +5 -5
- data/sig/authproto/http_client.rbs +2 -2
- data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
- data/sig/authproto/metadata/client_metadata.rbs +118 -0
- data/sig/authproto/metadata/resource_server.rbs +27 -0
- data/sig/authproto/minisky_oauth.rbs +5 -3
- data/sig/authproto/par/client.rbs +4 -6
- data/sig/authproto/par/client_assertion.rbs +2 -1
- data/sig/authproto/par/request.rbs +5 -19
- data/sig/authproto/par/response.rbs +1 -5
- data/sig/authproto/pkce.rbs +6 -16
- data/sig/authproto/serialization/session.rbs +2 -2
- data/sig/authproto/state/session.rbs +4 -7
- data/sig/authproto/state/session_manager.rbs +2 -2
- data/sig/authproto/token/refresh.rbs +4 -4
- data/sig/authproto/validated_uri.rbs +36 -0
- data/sig/authproto.rbs +5 -3
- metadata +13 -11
- data/lib/authproto/client_metadata.rb +0 -478
- data/lib/authproto/server_metadata/authorization_server.rb +0 -267
- data/lib/authproto/server_metadata/origin_url.rb +0 -71
- data/lib/authproto/server_metadata/resource_server.rb +0 -98
- data/sig/authproto/client_metadata.rbs +0 -113
- data/sig/authproto/server_metadata/origin_url.rbs +0 -21
- data/sig/authproto/server_metadata/resource_server.rbs +0 -27
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative '../configuration'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
require_relative '../http_client'
|
|
9
|
+
require_relative '../metadata'
|
|
10
|
+
require_relative '../validated_uri'
|
|
11
|
+
|
|
12
|
+
module AuthProto
|
|
13
|
+
module Metadata
|
|
14
|
+
|
|
15
|
+
#
|
|
16
|
+
# This class represents a Resource Server (PDS) and is responsible for
|
|
17
|
+
# validating and managing its metadata. It ensures that the authorization
|
|
18
|
+
# server URLs provided are valid and compliant with expected standards.
|
|
19
|
+
# The class also includes functionality to fetch and parse metadata from a
|
|
20
|
+
# remote URL, raising specific errors for invalid or malformed metadata.
|
|
21
|
+
#
|
|
22
|
+
|
|
23
|
+
class ResourceServer
|
|
24
|
+
|
|
25
|
+
# The single authorization server origin advertised by the resource server.
|
|
26
|
+
# @return [String]
|
|
27
|
+
attr_reader :authorization_server
|
|
28
|
+
|
|
29
|
+
# Validates resource server metadata and its single authorization server origin.
|
|
30
|
+
#
|
|
31
|
+
# @param metadata [Hash{String => Object}] discovery document containing `authorization_servers`
|
|
32
|
+
# @raise [InvalidAuthorizationServer] if the list is missing or does not contain one valid origin
|
|
33
|
+
# @raise [URI::InvalidURIError] if the authorization server URI cannot be parsed
|
|
34
|
+
#
|
|
35
|
+
def initialize(metadata)
|
|
36
|
+
@authorization_server = validate_authorization_servers(metadata["authorization_servers"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fetches `/.well-known/oauth-protected-resource` using the configured transport.
|
|
40
|
+
# Requires HTTP 200 without redirects, a JSON object served as application/json,
|
|
41
|
+
# and a resource identifier that exactly matches the requested URL.
|
|
42
|
+
# URL scheme restrictions are enforced by the configured transport.
|
|
43
|
+
# The PDS URL must have no userinfo, path prefix, query or fragment.
|
|
44
|
+
#
|
|
45
|
+
# @param url [String] resource server/PDS base URL
|
|
46
|
+
# @return [ResourceServer] validated resource server metadata
|
|
47
|
+
# @raise [InvalidAuthorizationServer] if HTTP transport, JSON parsing or metadata validation fails
|
|
48
|
+
# @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
|
|
49
|
+
# @raise [URI::InvalidURIError] if the PDS or authorization server URL cannot be parsed
|
|
50
|
+
#
|
|
51
|
+
def self.from_url(url)
|
|
52
|
+
validate_source_url(url)
|
|
53
|
+
|
|
54
|
+
response = fetch_metadata(url)
|
|
55
|
+
validate_response(response)
|
|
56
|
+
|
|
57
|
+
metadata = parse_metadata(response[:body])
|
|
58
|
+
validate_resource(metadata["resource"], url)
|
|
59
|
+
|
|
60
|
+
new(metadata)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate_authorization_servers(servers)
|
|
67
|
+
raise InvalidAuthorizationServer, "authorization_servers missing" if servers.nil?
|
|
68
|
+
raise InvalidAuthorizationServer, "authorization_servers is not an array" unless servers.is_a?(Array)
|
|
69
|
+
raise InvalidAuthorizationServer, "must have exactly one authorization server" unless servers.length == 1
|
|
70
|
+
|
|
71
|
+
uri = ValidatedURI.new(servers.first)
|
|
72
|
+
|
|
73
|
+
valid = (
|
|
74
|
+
uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
|
|
75
|
+
uri.path.empty? && !uri.query && !uri.fragment && !uri.userinfo
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
raise InvalidAuthorizationServer, "invalid authorization server URL format for #{servers.first}" unless valid
|
|
79
|
+
|
|
80
|
+
servers.first
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_source_url(url)
|
|
87
|
+
uri = ValidatedURI.new(url)
|
|
88
|
+
|
|
89
|
+
unless uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
|
|
90
|
+
raise InvalidAuthorizationServer,
|
|
91
|
+
"Resource server URL must be an HTTP(S) origin without userinfo, a path prefix, query or fragment"
|
|
92
|
+
end
|
|
93
|
+
rescue URI::InvalidURIError => e
|
|
94
|
+
raise InvalidAuthorizationServer, "Invalid resource server URL: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch_metadata(url)
|
|
98
|
+
metadata_url = URI.join(url, "/.well-known/oauth-protected-resource")
|
|
99
|
+
|
|
100
|
+
AuthProto.configuration.http_client.get(metadata_url.to_s)
|
|
101
|
+
rescue HTTPClient::HTTPError => e
|
|
102
|
+
raise InvalidAuthorizationServer, "Failed to fetch resource server metadata: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_response(response)
|
|
106
|
+
unless response[:status] == 200
|
|
107
|
+
raise InvalidAuthorizationServer, "Resource server metadata must return HTTP 200"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
|
|
111
|
+
|
|
112
|
+
unless content_type.casecmp?("application/json")
|
|
113
|
+
raise InvalidAuthorizationServer, "Resource server metadata must use application/json"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_metadata(body)
|
|
118
|
+
metadata = JSON.parse(body.to_s)
|
|
119
|
+
|
|
120
|
+
unless metadata.is_a?(Hash)
|
|
121
|
+
raise InvalidAuthorizationServer, "Resource server metadata must be a JSON object"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
metadata
|
|
125
|
+
rescue JSON::ParserError => e
|
|
126
|
+
raise InvalidAuthorizationServer, "Invalid JSON in resource server metadata: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_resource(resource, url)
|
|
130
|
+
unless resource.is_a?(String) && resource == url
|
|
131
|
+
raise InvalidAuthorizationServer, "Resource server metadata resource must match the requested resource URL"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module AuthProto
|
|
4
4
|
|
|
5
5
|
#
|
|
6
|
-
# Provides functionality for fetching and validating AT Protocol OAuth
|
|
7
|
-
#
|
|
6
|
+
# Provides functionality for fetching and validating AT Protocol OAuth metadata
|
|
7
|
+
# for clients, Resource Servers (PDS instances), and Authorization Servers (PDS/entryway).
|
|
8
8
|
#
|
|
9
9
|
# The flow for resolving an account's authorization server is:
|
|
10
10
|
# 1. Start with PDS URL
|
|
@@ -13,16 +13,16 @@ module AuthProto
|
|
|
13
13
|
# 4. Fetch Authorization Server metadata from /.well-known/oauth-authorization-server
|
|
14
14
|
#
|
|
15
15
|
# @example Resolving authorization server from PDS URL
|
|
16
|
-
# resource_server = AuthProto::
|
|
17
|
-
# auth_server_url = resource_server.
|
|
18
|
-
# auth_server = AuthProto::
|
|
16
|
+
# resource_server = AuthProto::Metadata::ResourceServer.from_url("https://pds.example.com")
|
|
17
|
+
# auth_server_url = resource_server.authorization_server
|
|
18
|
+
# auth_server = AuthProto::Metadata::AuthorizationServer.from_url(auth_server_url)
|
|
19
19
|
#
|
|
20
20
|
# The module includes three main classes:
|
|
21
21
|
# - {ResourceServer} - Handles PDS metadata validation and authorization server discovery
|
|
22
22
|
# - {AuthorizationServer} - Handles authorization server metadata validation
|
|
23
|
-
# - {
|
|
23
|
+
# - {ClientMetadata} - Handles client metadata and registration requirements
|
|
24
24
|
#
|
|
25
25
|
|
|
26
|
-
module
|
|
26
|
+
module Metadata
|
|
27
27
|
end
|
|
28
28
|
end
|
data/lib/authproto/minisky.rb
CHANGED
|
@@ -40,6 +40,7 @@ class Minisky::OAuthClient
|
|
|
40
40
|
# @param client_id [String] OAuth client metadata URL or localhost client ID
|
|
41
41
|
# @param redirect_uri [String] registered callback URI
|
|
42
42
|
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch it
|
|
43
|
+
# @param signing_key [Hash, JOSE::JWK, nil] private ES256 key for confidential-client authentication
|
|
43
44
|
# @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
|
|
44
45
|
#
|
|
45
46
|
# @raise [AuthProto::InvalidClientMetadata] if metadata is invalid or unavailable
|
|
@@ -47,7 +48,7 @@ class Minisky::OAuthClient
|
|
|
47
48
|
# @raise [AuthProto::HTTPClient::HTTPError, AuthProto::HTTPClient::SSRFError] if fetching metadata fails
|
|
48
49
|
# @see AuthProto::Client#initialize
|
|
49
50
|
#
|
|
50
|
-
def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, session_id: nil)
|
|
51
|
+
def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil, session_id: nil)
|
|
51
52
|
@host = host
|
|
52
53
|
@session_id = session_id
|
|
53
54
|
|
|
@@ -58,7 +59,8 @@ class Minisky::OAuthClient
|
|
|
58
59
|
client_id: client_id,
|
|
59
60
|
redirect_uri: redirect_uri,
|
|
60
61
|
metadata: metadata,
|
|
61
|
-
dpop_key: dpop_key
|
|
62
|
+
dpop_key: dpop_key,
|
|
63
|
+
signing_key: signing_key
|
|
62
64
|
)
|
|
63
65
|
end
|
|
64
66
|
|
|
@@ -72,8 +74,8 @@ class Minisky::OAuthClient
|
|
|
72
74
|
#
|
|
73
75
|
# @param options [Hash{Symbol => Object}] options forwarded to {AuthProto::Client#authorize}
|
|
74
76
|
# @option options [String] :handle account handle, optionally prefixed with `@`
|
|
75
|
-
# @option options [String] :
|
|
76
|
-
# @option options [String] :scope (
|
|
77
|
+
# @option options [String] :pds PDS URL, as an alternative to `handle`
|
|
78
|
+
# @option options [String] :scope ('atproto') requested scopes
|
|
77
79
|
#
|
|
78
80
|
# @return [Hash{Symbol => String}] `:url` to open and pending `:session_id`
|
|
79
81
|
# @raise [AuthProto::Error] if authorization, discovery, PAR or persistence fails
|
|
@@ -226,15 +228,11 @@ class Minisky::OAuthClient
|
|
|
226
228
|
nonce_retries = 0
|
|
227
229
|
refreshed = false
|
|
228
230
|
|
|
229
|
-
proof_url = url.dup
|
|
230
|
-
proof_url.query = nil
|
|
231
|
-
proof_url.fragment = nil
|
|
232
|
-
|
|
233
231
|
loop do
|
|
234
232
|
# Fresh proof for every attempt; callers cannot override OAuth auth headers.
|
|
235
233
|
request_headers = headers.reject { |k, v| %w[authorization dpop].include?(k.to_s.downcase) }
|
|
236
234
|
request_headers.merge!(oauth_client.auth_headers(
|
|
237
|
-
session_id: session_id, method: request_class::METHOD, url:
|
|
235
|
+
session_id: session_id, method: request_class::METHOD, url: url.to_s
|
|
238
236
|
))
|
|
239
237
|
|
|
240
238
|
request = request_class.new(url, request_headers)
|
|
@@ -245,7 +243,7 @@ class Minisky::OAuthClient
|
|
|
245
243
|
oauth_client.dpop_client.process_response(response.each_header.to_h, url.to_s) if nonce && !nonce.empty?
|
|
246
244
|
error = authentication_error(response)
|
|
247
245
|
|
|
248
|
-
if response.code.to_i == 401 && error ==
|
|
246
|
+
if response.code.to_i == 401 && error == AuthProto::DPoP::ERROR_USE_NONCE
|
|
249
247
|
unless nonce && !nonce.empty?
|
|
250
248
|
raise AuthProto::DPoP::Client::Error, "DPoP nonce challenge (HTTP 401) is missing DPoP-Nonce"
|
|
251
249
|
end
|
data/lib/authproto/par/client.rb
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'cgi'
|
|
4
3
|
require 'json'
|
|
5
4
|
require 'uri'
|
|
6
5
|
|
|
@@ -25,7 +24,7 @@ module AuthProto
|
|
|
25
24
|
#
|
|
26
25
|
# @example Basic PAR flow
|
|
27
26
|
# client = AuthProto::PAR::Client.new(
|
|
28
|
-
#
|
|
27
|
+
# authorization_server: authorization_server,
|
|
29
28
|
# dpop_client: AuthProto::DPoP::Client.new
|
|
30
29
|
# )
|
|
31
30
|
#
|
|
@@ -34,16 +33,14 @@ module AuthProto
|
|
|
34
33
|
# config.client_id = "https://app.example.com/oauth-client-metadata.json"
|
|
35
34
|
# config.redirect_uri = "https://app.example.com/callback"
|
|
36
35
|
# config.code_challenge = "abc123..."
|
|
37
|
-
# config.code_challenge_method = "S256"
|
|
38
36
|
# config.state = "xyz789..."
|
|
39
|
-
# config.scope =
|
|
37
|
+
# config.scope = 'atproto'
|
|
40
38
|
# end
|
|
41
39
|
#
|
|
42
40
|
# response = client.submit(request)
|
|
43
41
|
#
|
|
44
42
|
# # Build authorization URL using response
|
|
45
|
-
# auth_url = client.
|
|
46
|
-
# authorize_endpoint: "https://auth.example.com/authorize",
|
|
43
|
+
# auth_url = client.build_authorization_url(
|
|
47
44
|
# request_uri: response.request_uri,
|
|
48
45
|
# client_id: request.client_id
|
|
49
46
|
# )
|
|
@@ -51,8 +48,7 @@ module AuthProto
|
|
|
51
48
|
# @example With client authentication (confidential clients)
|
|
52
49
|
# request = AuthProto::PAR::Request.build do |config|
|
|
53
50
|
# # ... basic parameters ...
|
|
54
|
-
# config.
|
|
55
|
-
# config.client_assertion = jwt_token
|
|
51
|
+
# config.client_assertion = AuthProto::PAR::ClientAssertion.new(client_id: client_id, signing_key: signing_key)
|
|
56
52
|
# end
|
|
57
53
|
#
|
|
58
54
|
# All requests use HTTPS and include a fresh DPoP proof from the supplied
|
|
@@ -62,29 +58,28 @@ module AuthProto
|
|
|
62
58
|
|
|
63
59
|
class Client
|
|
64
60
|
|
|
61
|
+
# Authorization server supplying endpoints and the client assertion audience.
|
|
62
|
+
# @return [Metadata::AuthorizationServer]
|
|
63
|
+
attr_reader :authorization_server
|
|
64
|
+
|
|
65
65
|
# HTTPS pushed authorization request endpoint.
|
|
66
66
|
# @return [String]
|
|
67
|
-
attr_reader :
|
|
67
|
+
attr_reader :par_endpoint
|
|
68
68
|
|
|
69
69
|
# DPoP client used to sign requests.
|
|
70
70
|
# @return [DPoP::Client]
|
|
71
71
|
attr_reader :dpop_client
|
|
72
72
|
|
|
73
|
-
# Nonce manager belonging to the DPoP client.
|
|
74
|
-
# @return [DPoP::NonceManager]
|
|
75
|
-
attr_reader :nonce_manager
|
|
76
|
-
|
|
77
73
|
# Creates a PAR transport using the global HTTP client and supplied DPoP client.
|
|
78
74
|
#
|
|
79
|
-
# @param
|
|
75
|
+
# @param authorization_server [Metadata::AuthorizationServer] discovered authorization server metadata
|
|
80
76
|
# @param dpop_client [DPoP::Client] proof generator and nonce tracker
|
|
81
|
-
# @raise [Error] if the endpoint is malformed or does not use HTTPS
|
|
82
77
|
#
|
|
83
|
-
def initialize(
|
|
84
|
-
@
|
|
78
|
+
def initialize(authorization_server:, dpop_client:)
|
|
79
|
+
@authorization_server = authorization_server
|
|
80
|
+
@par_endpoint = authorization_server.pushed_authorization_request_endpoint
|
|
85
81
|
@dpop_client = dpop_client
|
|
86
82
|
@nonce_manager = dpop_client.nonce_manager
|
|
87
|
-
validate_endpoint
|
|
88
83
|
end
|
|
89
84
|
|
|
90
85
|
# Submits a PAR request, handling DPoP nonce requirements
|
|
@@ -94,6 +89,8 @@ module AuthProto
|
|
|
94
89
|
# @raise [Error] if request fails
|
|
95
90
|
#
|
|
96
91
|
def submit(request)
|
|
92
|
+
raise Error, "Expected a PAR::Request" unless request.is_a?(PAR::Request)
|
|
93
|
+
|
|
97
94
|
# Try the initial request
|
|
98
95
|
response = make_request(request)
|
|
99
96
|
|
|
@@ -105,7 +102,7 @@ module AuthProto
|
|
|
105
102
|
store_nonce(nonce)
|
|
106
103
|
|
|
107
104
|
# Get stored nonce to verify
|
|
108
|
-
nonce_manager.get(server_origin)
|
|
105
|
+
@nonce_manager.get(server_origin)
|
|
109
106
|
|
|
110
107
|
# Generate new proof with nonce and retry
|
|
111
108
|
response = make_request(request)
|
|
@@ -124,12 +121,7 @@ module AuthProto
|
|
|
124
121
|
# @raise [Error] if no supported nonce header is present
|
|
125
122
|
#
|
|
126
123
|
def extract_nonce(response)
|
|
127
|
-
|
|
128
|
-
headers = response[:headers]
|
|
129
|
-
nonce = headers["DPoP-Nonce"] ||
|
|
130
|
-
headers["dpop-nonce"] ||
|
|
131
|
-
headers["Dpop-Nonce"]
|
|
132
|
-
|
|
124
|
+
nonce = response[:headers].find { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
133
125
|
raise Error, "No DPoP nonce provided in response" unless nonce
|
|
134
126
|
|
|
135
127
|
nonce
|
|
@@ -137,38 +129,38 @@ module AuthProto
|
|
|
137
129
|
|
|
138
130
|
# Builds the browser URL using the client ID and returned PAR request URI.
|
|
139
131
|
#
|
|
140
|
-
#
|
|
132
|
+
# Retains existing query parameters.
|
|
141
133
|
#
|
|
142
|
-
# @param authorize_endpoint [String] authorization endpoint URL
|
|
143
134
|
# @param request_uri [String] request URI from {Response#request_uri}
|
|
144
135
|
# @param client_id [String] OAuth client ID
|
|
145
136
|
# @return [String] browser authorization URL
|
|
146
137
|
# @raise [URI::InvalidURIError] if the authorization endpoint cannot be parsed
|
|
147
138
|
#
|
|
148
|
-
def
|
|
149
|
-
uri = URI(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
139
|
+
def build_authorization_url(request_uri:, client_id:)
|
|
140
|
+
uri = URI(@authorization_server.authorization_endpoint)
|
|
141
|
+
|
|
142
|
+
params = {
|
|
143
|
+
request_uri: request_uri,
|
|
144
|
+
client_id: client_id
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
existing_query = uri.query || ''
|
|
148
|
+
params_query = URI.encode_www_form(params).gsub('*', '%2A') # unescaped '*' may break links in the terminal
|
|
149
|
+
|
|
150
|
+
uri.query = existing_query + (existing_query.empty? ? '' : '&') + params_query
|
|
151
|
+
|
|
154
152
|
uri.to_s
|
|
155
153
|
end
|
|
156
154
|
|
|
157
|
-
private
|
|
158
155
|
|
|
159
|
-
|
|
160
|
-
uri = URI(@endpoint)
|
|
161
|
-
raise Error, "endpoint must be HTTPS" unless uri.scheme == "https"
|
|
162
|
-
rescue URI::InvalidURIError => e
|
|
163
|
-
raise Error, "invalid endpoint URL: #{e.message}"
|
|
164
|
-
end
|
|
156
|
+
private
|
|
165
157
|
|
|
166
158
|
def make_request(request)
|
|
167
159
|
# Generate DPoP proof for this request
|
|
168
160
|
proof = dpop_client.generate_proof(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
nonce: nonce_manager.get(server_origin)
|
|
161
|
+
method: 'POST',
|
|
162
|
+
uri: @par_endpoint,
|
|
163
|
+
nonce: @nonce_manager.get(server_origin)
|
|
172
164
|
)
|
|
173
165
|
|
|
174
166
|
# Build headers including DPoP proof
|
|
@@ -176,8 +168,8 @@ module AuthProto
|
|
|
176
168
|
|
|
177
169
|
# Make the request
|
|
178
170
|
AuthProto.configuration.http_client.post(
|
|
179
|
-
|
|
180
|
-
body: request.to_form,
|
|
171
|
+
@par_endpoint,
|
|
172
|
+
body: request.to_form(audience: @authorization_server.issuer),
|
|
181
173
|
headers: headers
|
|
182
174
|
)
|
|
183
175
|
end
|
|
@@ -191,24 +183,24 @@ module AuthProto
|
|
|
191
183
|
|
|
192
184
|
def requires_nonce?(response)
|
|
193
185
|
body = JSON.parse(response[:body])
|
|
194
|
-
body[
|
|
186
|
+
body['error'] == DPoP::ERROR_USE_NONCE
|
|
195
187
|
rescue JSON::ParserError
|
|
196
188
|
false
|
|
197
189
|
end
|
|
198
190
|
|
|
199
191
|
def store_nonce(nonce)
|
|
200
|
-
nonce_manager.update(nonce: nonce, server_url: server_origin)
|
|
192
|
+
@nonce_manager.update(nonce: nonce, server_url: server_origin)
|
|
201
193
|
end
|
|
202
194
|
|
|
203
195
|
def server_origin
|
|
204
|
-
uri = URI(@
|
|
196
|
+
uri = URI(@par_endpoint)
|
|
205
197
|
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
|
|
206
198
|
end
|
|
207
199
|
|
|
208
200
|
def handle_error_response(response)
|
|
209
201
|
begin
|
|
210
202
|
error_data = JSON.parse(response[:body])
|
|
211
|
-
error_message = error_data[
|
|
203
|
+
error_message = error_data['error_description'] || error_data['error'] || "Unknown error"
|
|
212
204
|
rescue JSON::ParserError
|
|
213
205
|
error_message = "Invalid response from server"
|
|
214
206
|
end
|
|
@@ -219,11 +211,19 @@ module AuthProto
|
|
|
219
211
|
def process_response(response)
|
|
220
212
|
raise Error, "unexpected response status: #{response[:status]}" unless response[:status] == 201
|
|
221
213
|
|
|
214
|
+
content_type = (response[:headers] || {}).find { |key, _| key.downcase == 'content-type' }&.last
|
|
215
|
+
|
|
216
|
+
unless content_type.to_s.split(';', 2).first.to_s.strip.casecmp?('application/json')
|
|
217
|
+
raise Error, "PAR response must use application/json"
|
|
218
|
+
end
|
|
219
|
+
|
|
222
220
|
begin
|
|
223
221
|
data = JSON.parse(response[:body])
|
|
222
|
+
raise Error, "PAR response must be a JSON object" unless data.is_a?(Hash)
|
|
223
|
+
|
|
224
224
|
Response.new(
|
|
225
|
-
request_uri: data[
|
|
226
|
-
expires_in: data[
|
|
225
|
+
request_uri: data['request_uri'],
|
|
226
|
+
expires_in: data['expires_in']
|
|
227
227
|
)
|
|
228
228
|
rescue JSON::ParserError => e
|
|
229
229
|
raise Error, "invalid JSON response: #{e.message}"
|
|
@@ -231,10 +231,6 @@ module AuthProto
|
|
|
231
231
|
raise Error, "failed to process response: #{e.message}"
|
|
232
232
|
end
|
|
233
233
|
end
|
|
234
|
-
|
|
235
|
-
def encode_params(params)
|
|
236
|
-
params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
237
|
-
end
|
|
238
234
|
end
|
|
239
235
|
end
|
|
240
236
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'jwt'
|
|
4
|
+
require 'jose'
|
|
4
5
|
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
require_relative '../errors'
|
|
@@ -19,14 +20,21 @@ module AuthProto
|
|
|
19
20
|
# Raised when client assertion key import or JWT signing fails.
|
|
20
21
|
class Error < AuthProto::Error; end
|
|
21
22
|
|
|
23
|
+
attr_reader :signing_key
|
|
24
|
+
|
|
22
25
|
# Creates an ES256 client assertion signer.
|
|
23
26
|
#
|
|
24
27
|
# @param client_id [String] client ID used in `iss` and `sub`
|
|
25
|
-
# @param signing_key [JOSE::JWK] private EC P-256 signing key, including its `kid`
|
|
28
|
+
# @param signing_key [Hash, JOSE::JWK] private EC P-256 signing key, including its `kid`
|
|
29
|
+
# @raise [Error] if the key cannot sign with ES256 or its usage is incompatible
|
|
26
30
|
#
|
|
27
31
|
def initialize(client_id:, signing_key:)
|
|
28
32
|
@client_id = client_id
|
|
29
|
-
@signing_key = signing_key
|
|
33
|
+
@signing_key = validate_signing_key(signing_key)
|
|
34
|
+
rescue ClientAssertion::Error
|
|
35
|
+
raise
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise Error, "Invalid signing_key: #{e.message}"
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
# Signs a short-lived client authentication JWT with ES256.
|
|
@@ -42,28 +50,63 @@ module AuthProto
|
|
|
42
50
|
now = Time.now.to_i
|
|
43
51
|
|
|
44
52
|
payload = {
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
iat: now # Issued at time
|
|
53
|
+
iss: @client_id, # issuer
|
|
54
|
+
sub: @client_id, # subject
|
|
55
|
+
aud: audience, # audience is the authorization server issuer
|
|
56
|
+
jti: SecureRandom.uuid, # token identifier
|
|
57
|
+
exp: now + lifetime, # expiration time
|
|
58
|
+
iat: now # issued at time
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
# Header specifying ES256 algorithm for signing
|
|
55
61
|
header = {
|
|
56
|
-
alg:
|
|
57
|
-
typ:
|
|
58
|
-
kid: @signing_key.fields[
|
|
62
|
+
alg: 'ES256',
|
|
63
|
+
typ: 'JWT',
|
|
64
|
+
kid: @signing_key.fields['kid']
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
# Sign and return the JWT
|
|
62
67
|
key = JWT::JWK.import(@signing_key.to_map.to_h)
|
|
63
|
-
JWT.encode(payload, key,
|
|
68
|
+
JWT.encode(payload, key, 'ES256', header)
|
|
64
69
|
rescue JWT::Error, ArgumentError => e
|
|
65
70
|
raise Error, "Failed to generate client assertion: #{e.message}"
|
|
66
71
|
end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def validate_signing_key(signing_key)
|
|
77
|
+
data = signing_key.is_a?(JOSE::JWK) ? signing_key.to_map.to_h : signing_key
|
|
78
|
+
|
|
79
|
+
raise Error, "signing_key must be a hash or a JOSE::JWK" unless data.is_a?(Hash)
|
|
80
|
+
raise Error, "signing_key must be active" if data['revoked']
|
|
81
|
+
|
|
82
|
+
unless data['kty'] == 'EC' && data['crv'] == 'P-256'
|
|
83
|
+
raise Error, "signing_key must be a EC P-256 JWK"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unless data['d'].is_a?(String) && !data['d'].empty?
|
|
87
|
+
raise Error, "signing_key must be a private key ('d' field must not be empty)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
%w[kid x y].each do |field|
|
|
91
|
+
unless data[field].is_a?(String) && !data[field].empty?
|
|
92
|
+
raise Error, "signing_key '#{field}' field must not be empty"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if (data.key?('alg') && data['alg'] != 'ES256') ||
|
|
97
|
+
(data.key?('use') && data['use'] != 'sig') ||
|
|
98
|
+
(data.key?('key_ops') && (!data['key_ops'].is_a?(Array) || !data['key_ops'].include?('sign')))
|
|
99
|
+
raise Error, "signing_key must be active and permit ES256 signing"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
signing_key = JOSE::JWK.from_map(data)
|
|
103
|
+
|
|
104
|
+
imported = JWT::JWK.import(data)
|
|
105
|
+
proof = JWT.encode({}, imported, 'ES256')
|
|
106
|
+
JWT.decode(proof, imported.public_key, true, algorithms: ['ES256'])
|
|
107
|
+
|
|
108
|
+
signing_key
|
|
109
|
+
end
|
|
67
110
|
end
|
|
68
111
|
end
|
|
69
112
|
end
|