diff --git a/EXAMPLES.md b/EXAMPLES.md index 33050fbc5..1f86bcd29 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -16,6 +16,44 @@ client.authorization_url 'http://localhost:3000' # => # ``` +## Device Authorization Flow + +The [Device Authorization Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow) lets input-constrained devices, such as a CLI or a smart TV, obtain tokens by having the user complete the login in a browser elsewhere. + +Start the flow, show the user code to the user, then poll for the tokens until they finish: + +```ruby +require 'auth0' + +client = Auth0::Client.new( + client_id: ENV['AUTH0_RUBY_CLIENT_ID'], + domain: ENV['AUTH0_RUBY_DOMAIN'] +) + +flow = client.start_device_flow( + scope: 'openid profile offline_access', + audience: 'https://api.example.com' +) + +puts "Go to #{flow['verification_uri']} and enter the code #{flow['user_code']}" + +# Poll no more frequently than the interval Auth0 returns, until the code expires. +tokens = loop do + sleep flow['interval'] + + begin + break client.exchange_device_code_for_tokens(flow['device_code']) + rescue Auth0::HTTPError => e + # While the user has not finished, Auth0 answers with an `error` of + # `authorization_pending` or `slow_down`. Anything else is terminal. + error = JSON.parse(e.message)['error'] rescue nil + raise unless %w[authorization_pending slow_down].include?(error) + end +end + +tokens.access_token +``` + ## Management API Client As a simple example of how to get started with the Management API, we'll create an admin route to point to a list of all users from Auth0: diff --git a/lib/auth0/api/authentication_endpoints.rb b/lib/auth0/api/authentication_endpoints.rb index b01178147..433effe88 100644 --- a/lib/auth0/api/authentication_endpoints.rb +++ b/lib/auth0/api/authentication_endpoints.rb @@ -69,6 +69,40 @@ def exchange_auth_code_for_tokens( ::Auth0::AccessToken.from_response request_with_retry(:post, '/oauth/token', request_params) end + # Start a Device Authorization flow. + # @see https://auth0.com/docs/api/authentication#device-authorization-flow + # @param scope [string] Space-separated list of requested scopes. + # @param audience [string] Unique identifier of the target API. + # @param client_id [string] Client ID for the application + # @return [json] Returns device_code, user_code, verification_uri, + # verification_uri_complete, expires_in and interval. + def start_device_flow(scope: nil, audience: nil, client_id: @client_id) + request_params = { + client_id: client_id, + scope: scope, + audience: audience + } + + request_with_retry(:post, '/oauth/device/code', request_params) + end + + # Get access and ID tokens using a device code. + # @see https://auth0.com/docs/api/authentication#device-authorization-flow + # @param device_code [string] The device code returned by start_device_flow. + # @param client_id [string] Client ID for the application + # @return [Auth0::AccessToken] Returns the access_token and id_token + def exchange_device_code_for_tokens(device_code, client_id: @client_id) + raise Auth0::InvalidParameter, 'Must provide a device code' if device_code.to_s.empty? + + request_params = { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: client_id, + device_code: device_code + } + + ::Auth0::AccessToken.from_response request_with_retry(:post, '/oauth/token', request_params) + end + # Get access and ID tokens using a refresh token. # @see https://auth0.com/docs/api/authentication#refresh-token # @param refresh_token [string] Refresh token to use. Request this with diff --git a/test/unit/authentication_endpoints_test.rb b/test/unit/authentication_endpoints_test.rb index decf86386..8cf7d1d1b 100644 --- a/test/unit/authentication_endpoints_test.rb +++ b/test/unit/authentication_endpoints_test.rb @@ -177,6 +177,66 @@ def test_exchange_auth_code_for_tokens_with_client_assertion refute_nil result.access_token end + # --- start_device_flow --- + + def test_start_device_flow_requests_a_device_code + stub_request(:post, "https://#{@domain}/oauth/device/code") + .with do |req| + body = JSON.parse(req.body, symbolize_names: true) + body[:client_id] == @client_id && + body[:scope] == "openid profile" && + body[:audience] == "https://api.example.com" + end + .to_return( + status: 200, + body: { + "device_code" => "the_device_code", + "user_code" => "ABCD-EFGH", + "verification_uri" => "https://#{@domain}/activate", + "expires_in" => 900, + "interval" => 5 + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = @client_secret_instance.send( + :start_device_flow, scope: "openid profile", audience: "https://api.example.com" + ) + + assert_equal "the_device_code", result["device_code"] + assert_equal "ABCD-EFGH", result["user_code"] + assert_equal 5, result["interval"] + end + + # --- exchange_device_code_for_tokens --- + + def test_exchange_device_code_for_tokens + stub_request(:post, "https://#{@domain}/oauth/token") + .with do |req| + body = JSON.parse(req.body, symbolize_names: true) + body[:grant_type] == "urn:ietf:params:oauth:grant-type:device_code" && + body[:device_code] == "the_device_code" && + body[:client_id] == @client_id + end + .to_return( + status: 200, + body: { "id_token" => "id_token", "access_token" => "test_access_token", "expires_in" => 86_400 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = @client_secret_instance.send(:exchange_device_code_for_tokens, "the_device_code") + + assert_kind_of Auth0::AccessToken, result + refute_nil result.access_token + refute_nil result.id_token + end + + def test_exchange_device_code_for_tokens_raises_on_empty + assert_raises(Auth0::InvalidParameter) do + @client_secret_instance.send(:exchange_device_code_for_tokens, "") + end + end + # --- exchange_refresh_token --- def test_exchange_refresh_token_with_client_secret