From dc5454796422e5a7acc7dd99fde22a1088861abe Mon Sep 17 00:00:00 2001 From: Simon Claessens Date: Mon, 24 Aug 2026 16:01:11 +0200 Subject: [PATCH 1/3] feat: add Device Authorization Flow to the Authentication API --- EXAMPLES.md | 38 ++++++++++++++ lib/auth0/api/authentication_endpoints.rb | 34 ++++++++++++ test/unit/authentication_endpoints_test.rb | 60 ++++++++++++++++++++++ 3 files changed, 132 insertions(+) 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 ebc84f4ee..610aebe05 100644 --- a/lib/auth0/api/authentication_endpoints.rb +++ b/lib/auth0/api/authentication_endpoints.rb @@ -74,6 +74,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 f199915e9..a04f8b27c 100644 --- a/test/unit/authentication_endpoints_test.rb +++ b/test/unit/authentication_endpoints_test.rb @@ -248,6 +248,66 @@ def test_exchange_auth_code_for_tokens_with_code_verifier_on_a_public_client 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 From 0949a5f9bbf4eb0cf311ad2c82d925b31ad9b70e Mon Sep 17 00:00:00 2001 From: Simon Claessens Date: Tue, 8 Sep 2026 09:43:21 +0200 Subject: [PATCH 2/3] fix: address device flow review feedback Document that device flow runs as a public client and deliberately sends no client_secret or client assertion, and pin that in both tests so a later change to the auth behaviour cannot pass unnoticed. Back off by 5 seconds on slow_down in the polling example, per RFC 8628 section 3.5, and include verification_uri_complete in the stubbed device code response so it matches what Auth0 actually returns. --- EXAMPLES.md | 8 +++++++- lib/auth0/api/authentication_endpoints.rb | 6 ++++++ test/unit/authentication_endpoints_test.rb | 10 ++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1f86bcd29..b2bef2093 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -38,8 +38,10 @@ flow = client.start_device_flow( 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. +interval = flow['interval'] + tokens = loop do - sleep flow['interval'] + sleep interval begin break client.exchange_device_code_for_tokens(flow['device_code']) @@ -48,6 +50,10 @@ tokens = loop do # `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) + + # RFC 8628 section 3.5: on `slow_down`, add 5 seconds to the interval and + # keep polling at the slower rate from then on. + interval += 5 if error == 'slow_down' end end diff --git a/lib/auth0/api/authentication_endpoints.rb b/lib/auth0/api/authentication_endpoints.rb index 610aebe05..fe61dc95d 100644 --- a/lib/auth0/api/authentication_endpoints.rb +++ b/lib/auth0/api/authentication_endpoints.rb @@ -75,6 +75,9 @@ def exchange_auth_code_for_tokens( end # Start a Device Authorization flow. + # + # Device flow runs as a public client: no client_secret or client assertion is + # sent, and Auth0 rejects the request with `unauthorized_client` if one is. # @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. @@ -92,6 +95,9 @@ def start_device_flow(scope: nil, audience: nil, client_id: @client_id) end # Get access and ID tokens using a device code. + # + # Device flow runs as a public client: no client_secret or client assertion is + # sent, and Auth0 rejects the request with `unauthorized_client` if one is. # @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 diff --git a/test/unit/authentication_endpoints_test.rb b/test/unit/authentication_endpoints_test.rb index a04f8b27c..23f292372 100644 --- a/test/unit/authentication_endpoints_test.rb +++ b/test/unit/authentication_endpoints_test.rb @@ -256,7 +256,9 @@ def test_start_device_flow_requests_a_device_code body = JSON.parse(req.body, symbolize_names: true) body[:client_id] == @client_id && body[:scope] == "openid profile" && - body[:audience] == "https://api.example.com" + body[:audience] == "https://api.example.com" && + !body.key?(:client_secret) && + !body.key?(:client_assertion) end .to_return( status: 200, @@ -264,6 +266,7 @@ def test_start_device_flow_requests_a_device_code "device_code" => "the_device_code", "user_code" => "ABCD-EFGH", "verification_uri" => "https://#{@domain}/activate", + "verification_uri_complete" => "https://#{@domain}/activate?user_code=ABCD-EFGH", "expires_in" => 900, "interval" => 5 }.to_json, @@ -276,6 +279,7 @@ def test_start_device_flow_requests_a_device_code assert_equal "the_device_code", result["device_code"] assert_equal "ABCD-EFGH", result["user_code"] + assert_equal "https://#{@domain}/activate?user_code=ABCD-EFGH", result["verification_uri_complete"] assert_equal 5, result["interval"] end @@ -287,7 +291,9 @@ def test_exchange_device_code_for_tokens 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 + body[:client_id] == @client_id && + !body.key?(:client_secret) && + !body.key?(:client_assertion) end .to_return( status: 200, From 5b7040a1552e283fb8a61b6293f76f411dd2905b Mon Sep 17 00:00:00 2001 From: Simon Claessens Date: Tue, 8 Sep 2026 09:54:24 +0200 Subject: [PATCH 3/3] test: pin that device flow sends no client assertion either The two device tests only covered a client configured with a secret. Mirror the _with_client_secret / _with_client_assertion pair the sibling token methods use, so a configured signing key is proven not to be sent on either device endpoint. --- test/unit/authentication_endpoints_test.rb | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/unit/authentication_endpoints_test.rb b/test/unit/authentication_endpoints_test.rb index 23f292372..ecca66704 100644 --- a/test/unit/authentication_endpoints_test.rb +++ b/test/unit/authentication_endpoints_test.rb @@ -314,6 +314,47 @@ def test_exchange_device_code_for_tokens_raises_on_empty end end + def test_start_device_flow_sends_no_client_assertion + 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.key?(:client_assertion) && + !body.key?(:client_assertion_type) && + !body.key?(:client_secret) + end + .to_return( + status: 200, + body: { "device_code" => "the_device_code", "user_code" => "ABCD-EFGH" }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = @client_assertion_instance.send(:start_device_flow) + + assert_equal "the_device_code", result["device_code"] + end + + def test_exchange_device_code_for_tokens_sends_no_client_assertion + 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.key?(:client_assertion) && + !body.key?(:client_assertion_type) && + !body.key?(:client_secret) + end + .to_return( + status: 200, + body: { "access_token" => "test_access_token", "expires_in" => 86_400 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + result = @client_assertion_instance.send(:exchange_device_code_for_tokens, "the_device_code") + + assert_kind_of Auth0::AccessToken, result + refute_nil result.access_token + end + # --- exchange_refresh_token --- def test_exchange_refresh_token_with_client_secret