diff --git a/lib/workos.rb b/lib/workos.rb index f1c36fbc..b094f4fb 100644 --- a/lib/workos.rb +++ b/lib/workos.rb @@ -63,6 +63,7 @@ def self.key autoload :Invitation, 'workos/invitation' autoload :MagicAuth, 'workos/magic_auth' autoload :MFA, 'workos/mfa' + autoload :OAuthTokens, 'workos/oauth_tokens' autoload :Organization, 'workos/organization' autoload :Organizations, 'workos/organizations' autoload :OrganizationMembership, 'workos/organization_membership' diff --git a/lib/workos/authentication_response.rb b/lib/workos/authentication_response.rb index 84642790..9d675881 100644 --- a/lib/workos/authentication_response.rb +++ b/lib/workos/authentication_response.rb @@ -12,7 +12,8 @@ class AuthenticationResponse :access_token, :refresh_token, :authentication_method, - :sealed_session + :sealed_session, + :oauth_tokens # rubocop:disable Metrics/AbcSize def initialize(authentication_response_json, session = nil) @@ -27,6 +28,7 @@ def initialize(authentication_response_json, session = nil) reason: impersonator_json[:reason],) end @authentication_method = json[:authentication_method] + @oauth_tokens = json[:oauth_tokens] ? WorkOS::OAuthTokens.new(json[:oauth_tokens].to_json) : nil @sealed_session = if session && session[:seal_session] WorkOS::Session.seal_data({ @@ -49,6 +51,7 @@ def to_json(*) refresh_token: refresh_token, authentication_method: authentication_method, sealed_session: sealed_session, + oauth_tokens: oauth_tokens&.to_json, } end end diff --git a/lib/workos/oauth_tokens.rb b/lib/workos/oauth_tokens.rb new file mode 100644 index 00000000..f124085c --- /dev/null +++ b/lib/workos/oauth_tokens.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module WorkOS + # The OAuthTokens class represents the third party provider OAuth tokens returned in the authentication response. + # This class is not meant to be instantiated in user space, and is instantiated internally but exposed. + class OAuthTokens + include HashProvider + + attr_accessor :access_token, :refresh_token, :scopes, :expires_at + + def initialize(json) + hash = JSON.parse(json, symbolize_names: true) + + @access_token = hash[:access_token] + @refresh_token = hash[:refresh_token] + @scopes = hash[:scopes] + @expires_at = hash[:expires_at] + end + + def to_json(*) + { + access_token: access_token, + refresh_token: refresh_token, + scopes: scopes, + expires_at: expires_at, + } + end + end +end diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 878f6fef..d0b2b2dc 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -71,6 +71,7 @@ def load_sealed_session(client_id:, session_data:, cookie_password:) # field of the IdP sign-in page for the user, if you know their username ahead of time. # @param [String] domain_hint Can be used to pre-fill the domain field when # initiating authentication with Microsoft OAuth, or with a GoogleSAML connection type. + # @param [Array] provider_scopes An array of additional OAuth scopes to request from the provider. # @example # WorkOS::UserManagement.authorization_url( # connection_id: 'conn_123', @@ -96,7 +97,8 @@ def authorization_url( provider: nil, connection_id: nil, organization_id: nil, - state: '' + state: '', + provider_scopes: nil ) validate_authorization_url_arguments( @@ -115,6 +117,7 @@ def authorization_url( provider: provider, connection_id: connection_id, organization_id: organization_id, + provider_scopes: provider_scopes, }.compact) "https://#{WorkOS.config.api_hostname}/user_management/authorize?#{query}" diff --git a/spec/lib/workos/user_management_spec.rb b/spec/lib/workos/user_management_spec.rb index 18d3ca9a..2ed2939d 100644 --- a/spec/lib/workos/user_management_spec.rb +++ b/spec/lib/workos/user_management_spec.rb @@ -36,6 +36,32 @@ 'edit%22%7D&provider=authkit', ) end + + context 'with provider_scopes' do + it 'returns a valid authorization URL that includes provider_scopes' do + url = WorkOS::UserManagement.authorization_url( + provider: 'GoogleOAuth', + provider_scopes: %w[custom-scope-1 custom-scope-2], + client_id: 'workos-proj-123', + redirect_uri: 'foo.com/auth/callback', + state: { + next_page: '/dashboard/edit', + }.to_s, + ) + + expect(url).to eq( + 'https://api.workos.com/user_management/authorize?' \ + 'client_id=workos-proj-123' \ + '&redirect_uri=foo.com%2Fauth%2Fcallback' \ + '&response_type=code' \ + '&state=%7B%3Anext_page%3D%3E%22%2Fdashboard%2F' \ + 'edit%22%7D' \ + '&provider=GoogleOAuth' \ + '&provider_scopes=custom-scope-1' \ + '&provider_scopes=custom-scope-2', + ) + end + end end context 'with a connection selector' do @@ -453,6 +479,40 @@ end end + context 'when oauth_tokens is present in the api response' do + it 'returns an oauth_tokens object' do + VCR.use_cassette('user_management/authenticate_with_code/valid_with_oauth_tokens') do + authentication_response = WorkOS::UserManagement.authenticate_with_code( + code: '01H93ZZHA0JBHFJH9RR11S83YN', + client_id: 'client_123', + ip_address: '200.240.210.16', + user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/108.0.0.0 Safari/537.36', + ) + + expect(authentication_response.oauth_tokens).to be_a(WorkOS::OAuthTokens) + expect(authentication_response.oauth_tokens.access_token).to eq('oauth_access_token') + expect(authentication_response.oauth_tokens.refresh_token).to eq('oauth_refresh_token') + expect(authentication_response.oauth_tokens.scopes).to eq(%w[read write]) + expect(authentication_response.oauth_tokens.expires_at).to eq(1_234_567_890) + end + end + end + + context 'when oauth_tokens is not present in the api response' do + it 'returns nil oauth_tokens' do + VCR.use_cassette('user_management/authenticate_with_code/valid') do + authentication_response = WorkOS::UserManagement.authenticate_with_code( + code: '01H93ZZHA0JBHFJH9RR11S83YN', + client_id: 'client_123', + ip_address: '200.240.210.16', + user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/108.0.0.0 Safari/537.36', + ) + + expect(authentication_response.oauth_tokens).to be_nil + end + end + end + context 'when the user is being impersonated' do it 'contains the impersonator metadata' do VCR.use_cassette('user_management/authenticate_with_code/valid_with_impersonator') do diff --git a/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_code/valid_with_oauth_tokens.yml b/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_code/valid_with_oauth_tokens.yml new file mode 100644 index 00000000..7f5e0f05 --- /dev/null +++ b/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_code/valid_with_oauth_tokens.yml @@ -0,0 +1,82 @@ +--- +http_interactions: + - request: + method: post + uri: https://api.workos.com/user_management/authenticate + body: + encoding: UTF-8 + string: + '{"code":"01H93ZZHA0JBHFJH9RR11S83YN","client_id":"client_123","client_secret":"","ip_address":"200.240.210.16","user_agent":"Mozilla/5.0 + (Macintosh; Intel Mac OS X 10_15_7) Chrome/108.0.0.0 Safari/537.36","grant_type":"authorization_code"}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - WorkOS; ruby/3.0.2; arm64-darwin21; v2.16.0 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 30 Aug 2023 19:51:51 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 7fef921deeca091f-SEA + Cf-Cache-Status: + - DYNAMIC + Etag: + - W/"13b-pHataL1lHEvsW5EO4vq5QgAdcWw" + Strict-Transport-Security: + - max-age=15552000; includeSubDomains + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 spaces-router (devel) + Access-Control-Allow-Credentials: + - "true" + Content-Security-Policy: + - "default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' + https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src + 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + Expect-Ct: + - max-age=0 + Referrer-Policy: + - no-referrer + X-Content-Type-Options: + - nosniff + X-Dns-Prefetch-Control: + - "off" + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 630bec5a-5a13-4311-a0b7-958889a3bbb2 + X-Xss-Protection: + - "0" + Set-Cookie: + - __cf_bm=o5KBdIAUFZp0azSQnnd1GlQcIlcPCz95uFg6hFNnKM8-1693425111-0-ARSauqdojZdKD6Z7vp12JBrxCp6wE1s0JzEhaN0XE2DqME76OnJiDJugj2TsbNGXtqWaH3By7XHUXVZDf+AdFxU=; + path=/; expires=Wed, 30-Aug-23 20:21:51 GMT; domain=.workos.com; HttpOnly; + Secure; SameSite=None + - __cfruid=3e9a5d359ba92753e7626245fef8b2f1ee096477-1693425111; path=/; domain=.workos.com; + HttpOnly; Secure; SameSite=None + Server: + - cloudflare + body: + encoding: ASCII-8BIT + string: '{"user":{"object":"user","id":"user_01H93ZY4F80YZRRS6N59Z2HFVS","email":"test@workos.app","email_verified":false,"first_name":"Lucille","last_name":"Bluth","created_at":"2023-08-30T19:50:13.214Z","updated_at":"2023-08-30T19:50:13.214Z","user_type":"managed","sso_profile_id":"prof_01H93ZTVWYPAT4RKDSPFPPXH0J"},"access_token":"","refresh_token":"","oauth_tokens":{"access_token":"oauth_access_token","refresh_token":"oauth_refresh_token","scopes":["read","write"],"expires_at":1234567890}}' + http_version: + recorded_at: Wed, 30 Aug 2023 19:51:51 GMT +recorded_with: VCR 5.0.0