duo_universal 0.1.1.pre → 0.1.2.pre
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/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/lib/duo_universal/client.rb +175 -174
- data/lib/duo_universal/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac75cce798a71c31075b9c4b05f8a3b74e08bcef5aeda635c4b873eaa0fa1186
|
|
4
|
+
data.tar.gz: 67b13d79bad4b6cbb8fb3228d87440989ee827c68af07067eb93f4c89fffdbad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 781ddd2d1724540f1df66827fa26fb583aa28bda5975e98c74a9ddd2d5b00e0ada013cc67b414738f0f9373cf8a56a4e85bae0eb6f5d5429d8ece186d52bd3c1
|
|
7
|
+
data.tar.gz: c1289b853fa84bcda7ee6bc3f5eab0aa73a2ee21f019f67a0bc561be4f2340eeb770e20505094bf770fbda1259cbb89853b88f0f538429a9ebe74e73f5aff151
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -15,7 +15,7 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
|
15
15
|
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
See the Sinatra-based demo app found in the `demo` folder, for an example of how to integrate this gem into your project.
|
|
19
19
|
|
|
20
20
|
## Development
|
|
21
21
|
|
data/lib/duo_universal/client.rb
CHANGED
|
@@ -1,175 +1,176 @@
|
|
|
1
|
-
require 'jwt'
|
|
2
|
-
require 'securerandom'
|
|
3
|
-
require 'httparty'
|
|
4
|
-
|
|
5
|
-
require 'byebug'
|
|
6
|
-
|
|
7
|
-
module Duo
|
|
8
|
-
class Client
|
|
9
|
-
STATE_LENGTH = 36
|
|
10
|
-
JTI_LENGTH = 36
|
|
11
|
-
MINIMUM_STATE_LENGTH = 22
|
|
12
|
-
MAXIMUM_STATE_LENGTH = 1024
|
|
13
|
-
CLIENT_ID_LENGTH = 20
|
|
14
|
-
CLIENT_SECRET_LENGTH = 40
|
|
15
|
-
CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
16
|
-
JWT_LEEWAY = 60
|
|
17
|
-
|
|
18
|
-
attr_reader :client_id, :client_secret, :host, :redirect_uri, :use_duo_code_attribute
|
|
19
|
-
|
|
20
|
-
def initialize(client_id, client_secret, host, redirect_uri, optional_args = {})
|
|
21
|
-
raise Duo::ClientIDLengthError unless client_id && client_id.length == CLIENT_ID_LENGTH
|
|
22
|
-
raise Duo::ClientSecretLengthError unless client_secret && client_secret.length == CLIENT_SECRET_LENGTH
|
|
23
|
-
raise Duo::ApiHostRequiredError unless host
|
|
24
|
-
raise Duo::RedirectUriRequiredError unless redirect_uri
|
|
25
|
-
|
|
26
|
-
@client_id = client_id
|
|
27
|
-
@client_secret = client_secret
|
|
28
|
-
@host = host
|
|
29
|
-
@redirect_uri = redirect_uri
|
|
30
|
-
@use_duo_code_attribute = optional_args.fetch(:use_duo_code_attribute) { true }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def api_host_uri
|
|
34
|
-
"https://#{host}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def authorize_endpoint_uri
|
|
38
|
-
"https://#{host}/oauth/v1/authorize"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def health_check_endpoint_uri
|
|
42
|
-
"https://#{host}/oauth/v1/health_check"
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def token_endpoint_uri
|
|
46
|
-
"https://#{host}/oauth/v1/token"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def create_auth_url(username, state)
|
|
50
|
-
raise Duo::StateLengthError unless state && state.length >= MINIMUM_STATE_LENGTH && state.length <= MAXIMUM_STATE_LENGTH
|
|
51
|
-
raise Duo::UsernameRequiredError unless username && username.gsub(/\s*/, '').length > 0
|
|
52
|
-
|
|
53
|
-
jwt_args = {
|
|
54
|
-
scope: 'openid',
|
|
55
|
-
redirect_uri: redirect_uri,
|
|
56
|
-
client_id: client_id,
|
|
57
|
-
iss: client_id,
|
|
58
|
-
aud: api_host_uri,
|
|
59
|
-
exp: (Time.now + (5*60)).to_i,
|
|
60
|
-
state: state,
|
|
61
|
-
response_type: 'code',
|
|
62
|
-
duo_uname: username,
|
|
63
|
-
use_duo_code_attribute: use_duo_code_attribute,
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
req_jwt = JWT.encode(jwt_args, client_secret, 'HS512')
|
|
67
|
-
|
|
68
|
-
all_args = {
|
|
69
|
-
response_type: 'code',
|
|
70
|
-
client_id: client_id,
|
|
71
|
-
request: req_jwt
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
query_string = URI.encode_www_form all_args
|
|
75
|
-
|
|
76
|
-
"#{authorize_endpoint_uri}?#{query_string}"
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def generate_state
|
|
80
|
-
SecureRandom.alphanumeric(STATE_LENGTH)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Checks whether Duo is available.
|
|
84
|
-
# Returns:
|
|
85
|
-
# {'response': {'timestamp': <int:unix timestamp>}, 'stat': 'OK'}
|
|
86
|
-
# Raises:
|
|
87
|
-
#
|
|
88
|
-
def health_check
|
|
89
|
-
req_payload = {
|
|
90
|
-
client_assertion: JWT.encode(jwt_args_for(health_check_endpoint_uri), client_secret, 'HS512'),
|
|
91
|
-
client_id: client_id
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
# ToDo: Add Support for verifying SSL certificates
|
|
95
|
-
begin
|
|
96
|
-
res = HTTParty.post(health_check_endpoint_uri, body: req_payload)
|
|
97
|
-
|
|
98
|
-
json_resp = JSON.parse res.body
|
|
99
|
-
|
|
100
|
-
raise Duo::Error.new(json_resp) unless json_resp['stat'] == 'OK'
|
|
101
|
-
|
|
102
|
-
json_resp
|
|
103
|
-
rescue => e
|
|
104
|
-
raise e
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Exchanges the duo_code for a token with Duo to determine
|
|
109
|
-
# if the auth was successful.
|
|
110
|
-
# Argument:
|
|
111
|
-
# duo_code -- Authentication session transaction id
|
|
112
|
-
# returned by Duo
|
|
113
|
-
# username -- Name of the user authenticating with Duo
|
|
114
|
-
# nonce -- Random 36B string used to associate
|
|
115
|
-
# a session with an ID token
|
|
116
|
-
# Returns:
|
|
117
|
-
# A token with meta-data about the auth
|
|
118
|
-
# Raises:
|
|
119
|
-
# Duo::Error on error for invalid duo_codes, invalid credentials,
|
|
120
|
-
# or problems connecting to Duo
|
|
121
|
-
def exchange_authorization_code_for_2fa_result(duo_code, username, nonce = nil)
|
|
122
|
-
raise Duo::DuoCodeRequiredError unless duo_code
|
|
123
|
-
|
|
124
|
-
jwt_args = jwt_args_for(token_endpoint_uri)
|
|
125
|
-
|
|
126
|
-
all_args = {
|
|
127
|
-
grant_type: 'authorization_code',
|
|
128
|
-
code: duo_code,
|
|
129
|
-
redirect_uri: redirect_uri,
|
|
130
|
-
client_id: client_id,
|
|
131
|
-
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
132
|
-
client_assertion: JWT.encode(jwt_args, client_secret, 'HS512')
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
begin
|
|
136
|
-
user_agent = "duo_universal_ruby/#{Duo::VERSION} ruby/#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL} #{RUBY_PLATFORM}"
|
|
137
|
-
|
|
138
|
-
resp = HTTParty.post(token_endpoint_uri, body: all_args, headers: { user_agent: user_agent })
|
|
139
|
-
|
|
140
|
-
json_response_body = JSON.parse(resp.body)
|
|
141
|
-
|
|
142
|
-
raise Duo::Error.new(json_response_body) unless resp.code == 200
|
|
143
|
-
|
|
144
|
-
decoded_token = JWT.decode(json_response_body['id_token'], client_secret, true, {
|
|
145
|
-
algorithm: 'HS512',
|
|
146
|
-
iss: token_endpoint_uri,
|
|
147
|
-
verify_iss: true,
|
|
148
|
-
aud: client_id,
|
|
149
|
-
verify_aud: true,
|
|
150
|
-
exp_leeway: JWT_LEEWAY,
|
|
151
|
-
required_claims: ['exp', 'iat'],
|
|
152
|
-
verify_iat: true
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
require 'jwt'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
require 'httparty'
|
|
4
|
+
|
|
5
|
+
require 'byebug'
|
|
6
|
+
|
|
7
|
+
module Duo
|
|
8
|
+
class Client
|
|
9
|
+
STATE_LENGTH = 36
|
|
10
|
+
JTI_LENGTH = 36
|
|
11
|
+
MINIMUM_STATE_LENGTH = 22
|
|
12
|
+
MAXIMUM_STATE_LENGTH = 1024
|
|
13
|
+
CLIENT_ID_LENGTH = 20
|
|
14
|
+
CLIENT_SECRET_LENGTH = 40
|
|
15
|
+
CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
16
|
+
JWT_LEEWAY = 60
|
|
17
|
+
|
|
18
|
+
attr_reader :client_id, :client_secret, :host, :redirect_uri, :use_duo_code_attribute
|
|
19
|
+
|
|
20
|
+
def initialize(client_id, client_secret, host, redirect_uri, optional_args = {})
|
|
21
|
+
raise Duo::ClientIDLengthError unless client_id && client_id.length == CLIENT_ID_LENGTH
|
|
22
|
+
raise Duo::ClientSecretLengthError unless client_secret && client_secret.length == CLIENT_SECRET_LENGTH
|
|
23
|
+
raise Duo::ApiHostRequiredError unless host
|
|
24
|
+
raise Duo::RedirectUriRequiredError unless redirect_uri
|
|
25
|
+
|
|
26
|
+
@client_id = client_id
|
|
27
|
+
@client_secret = client_secret
|
|
28
|
+
@host = host
|
|
29
|
+
@redirect_uri = redirect_uri
|
|
30
|
+
@use_duo_code_attribute = optional_args.fetch(:use_duo_code_attribute) { true }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def api_host_uri
|
|
34
|
+
"https://#{host}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authorize_endpoint_uri
|
|
38
|
+
"https://#{host}/oauth/v1/authorize"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def health_check_endpoint_uri
|
|
42
|
+
"https://#{host}/oauth/v1/health_check"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def token_endpoint_uri
|
|
46
|
+
"https://#{host}/oauth/v1/token"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_auth_url(username, state)
|
|
50
|
+
raise Duo::StateLengthError unless state && state.length >= MINIMUM_STATE_LENGTH && state.length <= MAXIMUM_STATE_LENGTH
|
|
51
|
+
raise Duo::UsernameRequiredError unless username && username.gsub(/\s*/, '').length > 0
|
|
52
|
+
|
|
53
|
+
jwt_args = {
|
|
54
|
+
scope: 'openid',
|
|
55
|
+
redirect_uri: redirect_uri,
|
|
56
|
+
client_id: client_id,
|
|
57
|
+
iss: client_id,
|
|
58
|
+
aud: api_host_uri,
|
|
59
|
+
exp: (Time.now + (5*60)).to_i,
|
|
60
|
+
state: state,
|
|
61
|
+
response_type: 'code',
|
|
62
|
+
duo_uname: username,
|
|
63
|
+
use_duo_code_attribute: use_duo_code_attribute,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
req_jwt = JWT.encode(jwt_args, client_secret, 'HS512')
|
|
67
|
+
|
|
68
|
+
all_args = {
|
|
69
|
+
response_type: 'code',
|
|
70
|
+
client_id: client_id,
|
|
71
|
+
request: req_jwt
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
query_string = URI.encode_www_form all_args
|
|
75
|
+
|
|
76
|
+
"#{authorize_endpoint_uri}?#{query_string}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def generate_state
|
|
80
|
+
SecureRandom.alphanumeric(STATE_LENGTH)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks whether Duo is available.
|
|
84
|
+
# Returns:
|
|
85
|
+
# {'response': {'timestamp': <int:unix timestamp>}, 'stat': 'OK'}
|
|
86
|
+
# Raises:
|
|
87
|
+
#
|
|
88
|
+
def health_check
|
|
89
|
+
req_payload = {
|
|
90
|
+
client_assertion: JWT.encode(jwt_args_for(health_check_endpoint_uri), client_secret, 'HS512'),
|
|
91
|
+
client_id: client_id
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# ToDo: Add Support for verifying SSL certificates
|
|
95
|
+
begin
|
|
96
|
+
res = HTTParty.post(health_check_endpoint_uri, body: req_payload)
|
|
97
|
+
|
|
98
|
+
json_resp = JSON.parse res.body
|
|
99
|
+
|
|
100
|
+
raise Duo::Error.new(json_resp) unless json_resp['stat'] == 'OK'
|
|
101
|
+
|
|
102
|
+
json_resp
|
|
103
|
+
rescue => e
|
|
104
|
+
raise e
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Exchanges the duo_code for a token with Duo to determine
|
|
109
|
+
# if the auth was successful.
|
|
110
|
+
# Argument:
|
|
111
|
+
# duo_code -- Authentication session transaction id
|
|
112
|
+
# returned by Duo
|
|
113
|
+
# username -- Name of the user authenticating with Duo
|
|
114
|
+
# nonce -- Random 36B string used to associate
|
|
115
|
+
# a session with an ID token
|
|
116
|
+
# Returns:
|
|
117
|
+
# A token with meta-data about the auth
|
|
118
|
+
# Raises:
|
|
119
|
+
# Duo::Error on error for invalid duo_codes, invalid credentials,
|
|
120
|
+
# or problems connecting to Duo
|
|
121
|
+
def exchange_authorization_code_for_2fa_result(duo_code, username, nonce = nil)
|
|
122
|
+
raise Duo::DuoCodeRequiredError unless duo_code
|
|
123
|
+
|
|
124
|
+
jwt_args = jwt_args_for(token_endpoint_uri)
|
|
125
|
+
|
|
126
|
+
all_args = {
|
|
127
|
+
grant_type: 'authorization_code',
|
|
128
|
+
code: duo_code,
|
|
129
|
+
redirect_uri: redirect_uri,
|
|
130
|
+
client_id: client_id,
|
|
131
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
132
|
+
client_assertion: JWT.encode(jwt_args, client_secret, 'HS512')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
user_agent = "duo_universal_ruby/#{Duo::VERSION} ruby/#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL} #{RUBY_PLATFORM}"
|
|
137
|
+
|
|
138
|
+
resp = HTTParty.post(token_endpoint_uri, body: all_args, headers: { user_agent: user_agent })
|
|
139
|
+
|
|
140
|
+
json_response_body = JSON.parse(resp.body)
|
|
141
|
+
|
|
142
|
+
raise Duo::Error.new(json_response_body) unless resp.code == 200
|
|
143
|
+
|
|
144
|
+
decoded_token = JWT.decode(json_response_body['id_token'], client_secret, true, {
|
|
145
|
+
algorithm: 'HS512',
|
|
146
|
+
iss: token_endpoint_uri,
|
|
147
|
+
verify_iss: true,
|
|
148
|
+
aud: client_id,
|
|
149
|
+
verify_aud: true,
|
|
150
|
+
exp_leeway: JWT_LEEWAY,
|
|
151
|
+
required_claims: ['exp', 'iat'],
|
|
152
|
+
verify_iat: true
|
|
153
|
+
}).first
|
|
154
|
+
|
|
155
|
+
raise Duo::Error.new("The username is invalid.") unless decoded_token.has_key?('preferred_username') and decoded_token['preferred_username'] == username
|
|
156
|
+
raise Duo::Error.new("The nonce is invalid.") unless decoded_token.has_key?('nonce') and decoded_token['nonce'] == nonce
|
|
157
|
+
|
|
158
|
+
decoded_token
|
|
159
|
+
rescue => e
|
|
160
|
+
raise Duo::Error.new(e.message)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def jwt_args_for(endpoint_uri)
|
|
167
|
+
{
|
|
168
|
+
iss: client_id,
|
|
169
|
+
sub: client_id,
|
|
170
|
+
aud: endpoint_uri,
|
|
171
|
+
exp: (Time.now + (5*60)).to_i,
|
|
172
|
+
jti: SecureRandom.alphanumeric(JTI_LENGTH)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
175
176
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: duo_universal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2.pre
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Walter
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2022-08-
|
|
11
|
+
date: 2022-08-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: jwt
|