Skip to content

Commit 8eec6ef

Browse files
feat: support exchanging OTP codes for tokens (#438)
1 parent ab7a193 commit 8eec6ef

File tree

3 files changed

+238
-1
lines changed

3 files changed

+238
-1
lines changed

lib/auth0/api/authentication_endpoints.rb

+43
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module AuthenticationEndpoints
1313

1414
UP_AUTH = 'Username-Password-Authentication'.freeze
1515
JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'.freeze
16+
GRANT_TYPE_PASSWORDLESS_OPT = 'http://auth0.com/oauth/grant-type/passwordless/otp'.freeze
1617

1718
# Request an API access token using a Client Credentials grant
1819
# @see https://auth0.com/docs/api-auth/tutorials/client-credentials
@@ -94,6 +95,48 @@ def exchange_refresh_token(
9495
::Auth0::AccessToken.from_response request_with_retry(:post, '/oauth/token', request_params)
9596
end
9697

98+
# Exchange an OTP recieved through SMS for ID and access tokens
99+
# @param phone_number [string] The user's phone number used to receive the OTP
100+
# @param otp [string] The OTP contained in the SMS
101+
# @param audience [string] The audience for the access token (defaults to nil)
102+
# @param scope [string] The scope (defaults to 'openid profile email')
103+
def exchange_sms_otp_for_tokens(phone_number, otp, audience: nil, scope: nil)
104+
request_params = {
105+
grant_type: GRANT_TYPE_PASSWORDLESS_OPT,
106+
client_id: @client_id,
107+
username: phone_number,
108+
otp: otp,
109+
realm: 'sms',
110+
audience: audience,
111+
scope: scope || 'openid profile email'
112+
}
113+
114+
populate_client_assertion_or_secret(request_params)
115+
116+
::Auth0::AccessToken.from_response request_with_retry(:post, '/oauth/token', request_params)
117+
end
118+
119+
# Exchange an OTP recieved through email for ID and access tokens
120+
# @param email_address [string] The user's email address used to receive the OTP
121+
# @param otp [string] The OTP contained in the email
122+
# @param audience [string] The audience for the access token (defaults to nil)
123+
# @param scope [string] The scope (defaults to 'openid profile email')
124+
def exchange_email_otp_for_tokens(email_address, otp, audience: nil, scope: nil)
125+
request_params = {
126+
grant_type: GRANT_TYPE_PASSWORDLESS_OPT,
127+
client_id: @client_id,
128+
username: email_address,
129+
otp: otp,
130+
realm: 'email',
131+
audience: audience,
132+
scope: scope || 'openid profile email'
133+
}
134+
135+
populate_client_assertion_or_secret(request_params)
136+
137+
::Auth0::AccessToken.from_response request_with_retry(:post, '/oauth/token', request_params)
138+
end
139+
97140
# rubocop:disable Metrics/ParameterLists
98141
# Get access and ID tokens using Resource Owner Password.
99142
# Requires that your tenant has a Default Audience or Default Directory.

spec/lib/auth0/api/authentication_endpoints_spec.rb

+194
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,200 @@
237237
end
238238
end
239239

240+
context 'exchange_sms_otp_for_tokens', focus: true do
241+
it 'requests the tokens using an OTP from SMS' do
242+
expect(RestClient::Request).to receive(:execute) do |arg|
243+
expect(arg).to match(
244+
include(
245+
method: :post,
246+
url: 'https://samples.auth0.com/oauth/token'
247+
)
248+
)
249+
250+
payload = JSON.parse arg[:payload], symbolize_names: true
251+
252+
expect(payload[:grant_type]).to eq 'http://auth0.com/oauth/grant-type/passwordless/otp'
253+
expect(payload[:username]).to eq 'phone_number'
254+
expect(payload[:realm]).to eq 'sms'
255+
expect(payload[:otp]).to eq 'code'
256+
expect(payload[:client_id]).to eq client_id
257+
expect(payload[:client_secret]).to eq client_secret
258+
expect(payload[:scope]).to eq 'openid profile email'
259+
expect(payload[:audience]).to be_nil
260+
261+
StubResponse.new({
262+
"id_token" => "id_token",
263+
"access_token" => "test_access_token",
264+
"expires_in" => 86400},
265+
true,
266+
200)
267+
end
268+
269+
result = client_secret_instance.send :exchange_sms_otp_for_tokens, 'phone_number', 'code'
270+
271+
expect(result).to be_a_kind_of(Auth0::AccessToken)
272+
expect(result.id_token).not_to be_nil
273+
expect(result.access_token).not_to be_nil
274+
expect(result.expires_in).not_to be_nil
275+
end
276+
277+
it 'requests the tokens using OTP from SMS, and overrides scope and audience' do
278+
expect(RestClient::Request).to receive(:execute) do |arg|
279+
expect(arg).to match(
280+
include(
281+
method: :post,
282+
url: 'https://samples.auth0.com/oauth/token'
283+
)
284+
)
285+
286+
payload = JSON.parse arg[:payload], symbolize_names: true
287+
288+
expect(payload[:scope]).to eq 'openid'
289+
expect(payload[:audience]).to eq api_identifier
290+
291+
StubResponse.new({
292+
"id_token" => "id_token",
293+
"access_token" => "test_access_token",
294+
"expires_in" => 86400},
295+
true,
296+
200)
297+
end
298+
299+
result = client_secret_instance.send(:exchange_sms_otp_for_tokens, 'phone_number', 'code',
300+
audience: api_identifier,
301+
scope: 'openid'
302+
)
303+
304+
expect(result).to be_a_kind_of(Auth0::AccessToken)
305+
expect(result.id_token).not_to be_nil
306+
expect(result.access_token).not_to be_nil
307+
expect(result.expires_in).not_to be_nil
308+
end
309+
310+
it 'requests the tokens using an OTP from SMS using client assertion' do
311+
expect(RestClient::Request).to receive(:execute) do |arg|
312+
expect(arg).to match(
313+
include(
314+
method: :post,
315+
url: 'https://samples.auth0.com/oauth/token'
316+
)
317+
)
318+
319+
payload = JSON.parse arg[:payload], symbolize_names: true
320+
321+
expect(payload[:grant_type]).to eq 'http://auth0.com/oauth/grant-type/passwordless/otp'
322+
expect(payload[:client_secret]).to be_nil
323+
expect(payload[:client_assertion]).not_to be_nil
324+
expect(payload[:client_assertion_type]).to eq Auth0::ClientAssertion::CLIENT_ASSERTION_TYPE
325+
326+
StubResponse.new({
327+
"id_token" => "id_token",
328+
"access_token" => "test_access_token",
329+
"expires_in" => 86400},
330+
true,
331+
200)
332+
end
333+
334+
client_assertion_instance.send :exchange_sms_otp_for_tokens, 'phone_number', 'code'
335+
end
336+
end
337+
338+
context 'exchange_email_otp_for_tokens', focus: true do
339+
it 'requests the tokens using email OTP' do
340+
expect(RestClient::Request).to receive(:execute) do |arg|
341+
expect(arg).to match(
342+
include(
343+
method: :post,
344+
url: 'https://samples.auth0.com/oauth/token'
345+
)
346+
)
347+
348+
payload = JSON.parse arg[:payload], symbolize_names: true
349+
350+
expect(payload[:grant_type]).to eq 'http://auth0.com/oauth/grant-type/passwordless/otp'
351+
expect(payload[:username]).to eq 'email_address'
352+
expect(payload[:realm]).to eq 'email'
353+
expect(payload[:otp]).to eq 'code'
354+
expect(payload[:client_id]).to eq client_id
355+
expect(payload[:client_secret]).to eq client_secret
356+
expect(payload[:scope]).to eq 'openid profile email'
357+
expect(payload[:audience]).to be_nil
358+
359+
StubResponse.new({
360+
"id_token" => "id_token",
361+
"access_token" => "test_access_token",
362+
"expires_in" => 86400},
363+
true,
364+
200)
365+
end
366+
367+
result = client_secret_instance.send :exchange_email_otp_for_tokens, 'email_address', 'code'
368+
369+
expect(result).to be_a_kind_of(Auth0::AccessToken)
370+
expect(result.id_token).not_to be_nil
371+
expect(result.access_token).not_to be_nil
372+
expect(result.expires_in).not_to be_nil
373+
end
374+
375+
it 'requests the tokens using OTP from email, and overrides scope and audience' do
376+
expect(RestClient::Request).to receive(:execute) do |arg|
377+
expect(arg).to match(
378+
include(
379+
method: :post,
380+
url: 'https://samples.auth0.com/oauth/token'
381+
)
382+
)
383+
384+
payload = JSON.parse arg[:payload], symbolize_names: true
385+
386+
expect(payload[:scope]).to eq 'openid'
387+
expect(payload[:audience]).to eq api_identifier
388+
389+
StubResponse.new({
390+
"id_token" => "id_token",
391+
"access_token" => "test_access_token",
392+
"expires_in" => 86400},
393+
true,
394+
200)
395+
end
396+
397+
client_secret_instance.send(:exchange_email_otp_for_tokens, 'email_address', 'code',
398+
audience: api_identifier,
399+
scope: 'openid'
400+
)
401+
end
402+
403+
it 'requests the tokens using OTP from email using client assertion' do
404+
expect(RestClient::Request).to receive(:execute) do |arg|
405+
expect(arg).to match(
406+
include(
407+
method: :post,
408+
url: 'https://samples.auth0.com/oauth/token'
409+
)
410+
)
411+
412+
payload = JSON.parse arg[:payload], symbolize_names: true
413+
414+
expect(payload[:grant_type]).to eq 'http://auth0.com/oauth/grant-type/passwordless/otp'
415+
expect(payload[:client_secret]).to be_nil
416+
expect(payload[:client_assertion]).not_to be_nil
417+
expect(payload[:client_assertion_type]).to eq Auth0::ClientAssertion::CLIENT_ASSERTION_TYPE
418+
419+
StubResponse.new({
420+
"id_token" => "id_token",
421+
"access_token" => "test_access_token",
422+
"expires_in" => 86400},
423+
true,
424+
200)
425+
end
426+
427+
client_assertion_instance.send(:exchange_email_otp_for_tokens, 'email_address', 'code',
428+
audience: api_identifier,
429+
scope: 'openid'
430+
)
431+
end
432+
end
433+
240434
context 'login_with_resource_owner' do
241435
it 'logs in using a client secret' do
242436
expect(RestClient::Request).to receive(:execute) do |arg|

spec/lib/auth0/api/v2/clients_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
it { expect { @instance.client_credential('1', '') }.to raise_error 'Must specify a credential id' }
142142
end
143143

144-
context '.delete_client_credential', focus: true do
144+
context '.delete_client_credential' do
145145
it { expect(@instance).to respond_to(:delete_client_credential) }
146146

147147
it 'is expected to delete /api/v2/clients/1/credentials/2' do

0 commit comments

Comments
 (0)