sign_in_service 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 468897d228e6fcb4e60136303880ae7cc81ef29f134a270b5935e136971f4fd0
4
+ data.tar.gz: 952139fe857083c0765a3db19f50981d124abefefd2fb2153e3081d60c7a3a06
5
+ SHA512:
6
+ metadata.gz: 85c5094f9a5366f63ebb4d02d1a782ac68e00be274ad46ee434f69332eb05ad11c8894cdb012d7aeccccebb5dac52b095f2ad6c59d2265799aad012f2a131742
7
+ data.tar.gz: a47ddae9310aa566e650f4219409fa3f5a7d9a3f3dc194078e9aca22bfbe4561bb6528fdd2120efb1bc547d2ed3d59747e9163190c9f28309a629419cb9aa955
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,40 @@
1
+ require:
2
+ - rubocop-rspec
3
+ - rubocop-rake
4
+
5
+ AllCops:
6
+ NewCops: disable
7
+
8
+ Metrics/MethodLength:
9
+ Max: 20
10
+
11
+ Style/Documentation:
12
+ Enabled: false
13
+
14
+ Metrics/CyclomaticComplexity:
15
+ Enabled: false
16
+
17
+ Metrics/BlockLength:
18
+ Enabled: false
19
+
20
+ Metrics/AbcSize:
21
+ Enabled: false
22
+
23
+ Layout/MultilineMethodCallIndentation:
24
+ EnforcedStyle: indented
25
+
26
+ # RSpec Cops
27
+ RSpec/ExampleLength:
28
+ Enabled: false
29
+
30
+ RSpec/MultipleMemoizedHelpers:
31
+ Enabled: false
32
+
33
+ RSpec/NestedGroups:
34
+ Enabled: false
35
+
36
+ RSpec/DescribeClass:
37
+ Enabled: false
38
+
39
+ RSpec/MultipleExpectations:
40
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3
data/Gemfile ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" }
4
+
5
+ source 'https://rubygems.org'
6
+
7
+ gem 'faraday'
8
+ gem 'jwt'
9
+ gem 'rake'
10
+
11
+ group :test do
12
+ gem 'rack-test'
13
+ gem 'rspec'
14
+ end
15
+
16
+ group :development, :test do
17
+ gem 'pry-byebug'
18
+ gem 'rubocop', require: false
19
+ gem 'rubocop-rake', require: false
20
+ gem 'rubocop-rspec', require: false
21
+ gem 'vcr', require: false
22
+ gem 'webmock', require: false
23
+ end
24
+
25
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,115 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sign_in_service (0.4.0)
5
+ faraday (~> 2.7)
6
+ jwt (~> 2.8)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.6)
12
+ public_suffix (>= 2.0.2, < 6.0)
13
+ ast (2.4.2)
14
+ base64 (0.2.0)
15
+ bigdecimal (3.1.8)
16
+ byebug (11.1.3)
17
+ coderay (1.1.3)
18
+ crack (1.0.0)
19
+ bigdecimal
20
+ rexml
21
+ diff-lcs (1.5.1)
22
+ faraday (2.11.0)
23
+ faraday-net_http (>= 2.0, < 3.4)
24
+ logger
25
+ faraday-net_http (3.3.0)
26
+ net-http
27
+ hashdiff (1.1.0)
28
+ json (2.7.2)
29
+ jwt (2.8.2)
30
+ base64
31
+ language_server-protocol (3.17.0.3)
32
+ logger (1.6.0)
33
+ method_source (1.0.0)
34
+ net-http (0.4.1)
35
+ uri
36
+ parallel (1.25.1)
37
+ parser (3.3.4.0)
38
+ ast (~> 2.4.1)
39
+ racc
40
+ pry (0.14.2)
41
+ coderay (~> 1.1)
42
+ method_source (~> 1.0)
43
+ pry-byebug (3.10.1)
44
+ byebug (~> 11.0)
45
+ pry (>= 0.13, < 0.15)
46
+ public_suffix (5.0.5)
47
+ racc (1.8.1)
48
+ rack (3.0.9.1)
49
+ rack-test (2.1.0)
50
+ rack (>= 1.3)
51
+ rainbow (3.1.1)
52
+ rake (13.2.1)
53
+ regexp_parser (2.9.2)
54
+ rexml (3.3.6)
55
+ strscan
56
+ rspec (3.13.0)
57
+ rspec-core (~> 3.13.0)
58
+ rspec-expectations (~> 3.13.0)
59
+ rspec-mocks (~> 3.13.0)
60
+ rspec-core (3.13.0)
61
+ rspec-support (~> 3.13.0)
62
+ rspec-expectations (3.13.0)
63
+ diff-lcs (>= 1.2.0, < 2.0)
64
+ rspec-support (~> 3.13.0)
65
+ rspec-mocks (3.13.0)
66
+ diff-lcs (>= 1.2.0, < 2.0)
67
+ rspec-support (~> 3.13.0)
68
+ rspec-support (3.13.0)
69
+ rubocop (1.65.1)
70
+ json (~> 2.3)
71
+ language_server-protocol (>= 3.17.0)
72
+ parallel (~> 1.10)
73
+ parser (>= 3.3.0.2)
74
+ rainbow (>= 2.2.2, < 4.0)
75
+ regexp_parser (>= 2.4, < 3.0)
76
+ rexml (>= 3.2.5, < 4.0)
77
+ rubocop-ast (>= 1.31.1, < 2.0)
78
+ ruby-progressbar (~> 1.7)
79
+ unicode-display_width (>= 2.4.0, < 3.0)
80
+ rubocop-ast (1.32.0)
81
+ parser (>= 3.3.1.0)
82
+ rubocop-rake (0.6.0)
83
+ rubocop (~> 1.0)
84
+ rubocop-rspec (3.0.4)
85
+ rubocop (~> 1.61)
86
+ ruby-progressbar (1.13.0)
87
+ strscan (3.1.0)
88
+ unicode-display_width (2.5.0)
89
+ uri (0.13.0)
90
+ vcr (6.2.0)
91
+ webmock (3.23.1)
92
+ addressable (>= 2.8.0)
93
+ crack (>= 0.3.2)
94
+ hashdiff (>= 0.4.0, < 2.0.0)
95
+
96
+ PLATFORMS
97
+ ruby
98
+ x86_64-linux
99
+
100
+ DEPENDENCIES
101
+ faraday
102
+ jwt
103
+ pry-byebug
104
+ rack-test
105
+ rake
106
+ rspec
107
+ rubocop
108
+ rubocop-rake
109
+ rubocop-rspec
110
+ sign_in_service!
111
+ vcr
112
+ webmock
113
+
114
+ BUNDLED WITH
115
+ 2.4.9
data/LICENSE.txt ADDED
@@ -0,0 +1,16 @@
1
+ As a work of the United States Government, this project is in the public domain within the United States.
2
+
3
+ Additionally, we waive copyright and related rights in the work worldwide through the CC0 1.0 Universal public domain dedication.
4
+
5
+ CC0 1.0 Universal Summary
6
+ This is a human-readable summary of the Legal Code (read the full text).
7
+
8
+ No Copyright
9
+ The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
10
+
11
+ You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.
12
+
13
+ Other Information
14
+ In no way are the patent or trademark rights of any person affected by CC0, nor are the rights that other persons may have in the work or in how the work is used, such as publicity or privacy rights.
15
+
16
+ Unless expressly stated otherwise, the person who associated a work with this deed makes no warranties about the work, and disclaims liability for all uses of the work, to the fullest extent permitted by applicable law. When using or citing the work, you should not imply endorsement by the author or the affirmer.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ Note: This repo is managed by the VA.gov Identity team. Please reference our main product page here for contact information and questions.
2
+
3
+ # SignInService Ruby Client
4
+
5
+ The SignInService Ruby client provides a simple and convenient way to interact with the SignInService API for handling OAuth flows.
6
+
7
+ ## Installation
8
+
9
+ Add gem to your Gemfile
10
+ ```ruby
11
+ gem 'sign_in_service', :git => 'git://github.com/department-of-veterans-affairs/sign-in-service-rb.git'
12
+ ```
13
+
14
+ ```bash
15
+ bundle install
16
+ ```
17
+ ## Client Configuration
18
+
19
+ Configure the SignInService client with your base URL, client ID, and authentication type in an initializer:
20
+
21
+ ```ruby
22
+ require 'sign_in_service'
23
+
24
+ SignInService::Client.configure do |config|
25
+ config.base_url = 'https://your_sign_in_service_url'
26
+ config.client_id = 'your_client_id'
27
+ config.auth_type = :cookie # or :api
28
+ end
29
+ ```
30
+
31
+ ### Auth Types: Cookie vs API
32
+ The SignInService client supports two authentication types: Cookie and API.
33
+
34
+ #### Cookie Authentication
35
+ With Cookie authentication, tokens are returned in the `Set-Cookie` headers of the response. This approach is typically used in web applications where cookies can be stored and managed directly by the browser.
36
+
37
+ #### API Authentication
38
+ With API authentication, tokens are returned in the response body. This approach is typically used in non-web applications or scenarios where the application handles the tokens directly, such as mobile apps, desktop apps, or server-side scripts.
39
+
40
+ ### Endpoints
41
+
42
+ #### Authorization
43
+ - [Authorize](docs/endpoints/authorize.md) - Initiate the OAuth flow
44
+ - [Token](docs/endpoints/token.md) - Exchange authorization code for session tokens
45
+
46
+ #### Session Management
47
+ - [Refresh](docs/endpoints/refresh.md) - Refresh session tokens.
48
+ - [Logout](docs/endpoints/logout.md) - Log out the user and revoke tokens.
49
+ - [Revoke Token](docs/endpoints/revoke_token.md) - Revoke a sessions tokens.
50
+ - [Revoke All Sessions](docs/endpoints/revoke_all_sessions.md) - Revoke all sessions associated with a user
51
+
52
+
53
+ ## STS Configuration
54
+
55
+ ```ruby
56
+
57
+ SignInService::Sts.configure do |config|
58
+ config.base_url = 'https://api.va.gov' # Sign-in service URL
59
+ config.service_account_id = 'your_client_id'
60
+ config.issuer = 'your_client_secret',
61
+ config.scopes = ["https://api.va.gov/v0/some-path"]
62
+ config.user_attributes = ['icn'] # optional
63
+ config.private_key_path = 'path/to/private_key.pem'
64
+ end
65
+ ```
66
+
67
+ Or create a new instance of the STS client with the configuration (overrides the global configuration):
68
+
69
+ ```ruby
70
+ sts_client = SignInService::Sts.new(
71
+ base_url: 'https://api.va.gov',
72
+ service_account_id: 'your_client_id',
73
+ issuer: 'your_client_secret',
74
+ scopes: ["https://api.va.gov/v0/some-path"],
75
+ user_attributes: ['icn'], # optional
76
+ private_key_path: 'path/to/private_key.pem'
77
+ )
78
+ ```
79
+
80
+ #### Token - `/v0/sign_in/token`
81
+
82
+ When requesting a token you need to pass in a user_identifier to be used as the subject of the token. This can be an ICN, email, or any other unique identifier.
83
+
84
+ ```ruby
85
+ sts = SignInService::Sts.new(user_identifier: '1234567890') # And other configuration options not in the global configuration
86
+
87
+ token = sts.request_token
88
+ ```
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,62 @@
1
+ # Authorize
2
+
3
+ The Authorize Endpoint is responsible for initiating the OAuth 2.0 authorization flow. You can generate an authorization URL, or use the authorization endpoint.
4
+
5
+ ## Generating Authorize URL
6
+ The `authorize_url` method is used to generate a URL.
7
+
8
+ ### Parameters
9
+
10
+ - `type` (required): The CSP type.
11
+ - `acr` (required): Level of authentication requested.
12
+ - `code_challenge` (required): Used by SignInService to verify requests.
13
+ - `state` (optional): Optional string that can be returned in the callback.
14
+
15
+ ### Example
16
+
17
+ ```ruby
18
+ client = SignInService::Client.new
19
+
20
+ type = "your_csp_type"
21
+ acr = "your_acr"
22
+ code_challenge = "your_code_challenge"
23
+ state = "your_state" # Optional
24
+
25
+ auth_url = client.authorize_uri(type: type, acr: acr, code_challenge: code_challenge, state: state)
26
+ ```
27
+
28
+ ## Handling Responses
29
+ The `authorize_uri` method returns an authorization URL as a string. You can use this URL to redirect users to the SignInService authorization page.
30
+
31
+ ```ruby
32
+ redirect_to uri
33
+ ```
34
+
35
+ ## Authorize Endpoint
36
+ The `authorize` method is used to interact with the Authorize Endpoint
37
+
38
+ ### Parameters
39
+ - `type` (required): The CSP type.
40
+ - `acr` (required): Level of authentication requested.
41
+ - `code_challenge` (required): Used by SignInService to verify requests.
42
+ - `state` (optional): Optional string that can be returned in the callback.
43
+
44
+ ### Example Usage
45
+ ```ruby
46
+ client = SignInService::Client.new
47
+
48
+ # Required parameters
49
+ type = "your_csp_type"
50
+ acr = "your_acr"
51
+ code_challenge = "your_code_challenge"
52
+ state = "your_state" # Optional
53
+
54
+ response = client.authorize(type: type, acr: acr, code_challenge: code_challenge, state: state)
55
+ ```
56
+
57
+ ## Handling Responses
58
+ The `authorize` method returns a Faraday::Response object, which contains an HTML body used to redirect to the CSP
59
+
60
+ ```ruby
61
+ render response.body
62
+ ```
@@ -0,0 +1,31 @@
1
+ # Logout
2
+ The Logout Endpoint is responsible for logging out the user and revoking tokens.
3
+ ## Usage
4
+
5
+ ##### Parameters
6
+
7
+ - `access_token` (required): The access token for the associated user.
8
+ - `anti_csrf_token` (optional): An optional token for client-side CSRF protection.
9
+
10
+ ##### Example Usage
11
+
12
+ ```ruby
13
+ client = SignInService::Client.new
14
+
15
+ access_token = "your_id_token_hint"
16
+ anti_csrf_token = "your_anti_csrf_token" # Optional
17
+
18
+ response = client.logout(access_token: access_token, anti_csrf_token: anti_csrf_token)
19
+ ```
20
+
21
+ #### Handling Responses
22
+ The `logout` method returns a Faraday::Response object.
23
+
24
+ In the case of successful logout from login.gov you can expect an HTTP status code of 302. You will need to
25
+ redirect to the location header and expect a callback
26
+
27
+ ```ruby
28
+ redirect_to response.headers['location'] if response.status == 302
29
+ ```
30
+
31
+ In the case of id.me you will receive a 200 with an empty body
@@ -0,0 +1,46 @@
1
+ # Refresh Token
2
+
3
+ The Refresh Token Endpoint is responsible for refreshing session tokens.
4
+
5
+ ## Usage
6
+
7
+ ### Parameters
8
+ - `refresh_token` (required): The refresh token associated with the user's session.
9
+ - `anti_csrf_token` (optional): An optional token for client-side CSRF protection.
10
+
11
+ ### Example Usage
12
+ ``` ruby
13
+ client = SignInService::Client.new
14
+
15
+ refresh_token = "your_refresh_token"
16
+ anti_csrf_token = "your_anti_csrf_token" # Optional
17
+
18
+ response = client.refresh_token(refresh_token: refresh_token, anti_csrf_token: anti_csrf_token)
19
+ ```
20
+
21
+ ## Handling Responses
22
+ The `refresh_token` method returns a Faraday::Response object.
23
+
24
+ ### `:cookie` auth type
25
+ Tokens are returned in the `Set-Cookie` headers
26
+
27
+ ```ruby
28
+ response.headers['set-cookie']
29
+
30
+ # vagov_access_token=..., vagov_refresh_token=..., vagov_anti_csrf_token=..., vagov_info_token=...
31
+ ```
32
+
33
+ ### `:api` auth type
34
+ Tokens are returned in the response body
35
+
36
+ ```ruby
37
+ JSON.parse(response.body)
38
+
39
+ # {
40
+ # "data": {
41
+ # "access_token": "<accessTokenHash>", // JWT eyJhbGci0... etc
42
+ # "refresh_token": "<refreshTokenHash>", // v1:secure+data+AZX9...
43
+ # "anti_csrf_token": "<antiCsrfTokenHash>" // be5aac9...
44
+ # }
45
+ # }
46
+ ```
@@ -0,0 +1,22 @@
1
+ # Revoke All Sessions
2
+ The Revoke All Sessions Endpoint is responsible for revoking all sessions associated with a user.
3
+
4
+ ## Usage
5
+
6
+ ##### Parameters
7
+
8
+ - `access_token` (required): The access token associated with the user's session.
9
+
10
+ ##### Example Usage
11
+
12
+ ```ruby
13
+ client = SignInService::Client.new
14
+
15
+ access_token access_token = "your_access_token"
16
+
17
+ response = client.revoke_all_sessions(access_token: access_token)
18
+ ```
19
+
20
+ #### Handling Responses
21
+
22
+ The `revoke_all_sessions` method returns a Faraday::Response object. If successful, the response contains an HTTP status code of 200.
@@ -0,0 +1,23 @@
1
+ # Revoke Token
2
+
3
+ The Revoke Token Endpoint is responsible for revoking a session.
4
+
5
+ ## Usage
6
+
7
+ ### Parameters
8
+
9
+ - `refresh_token` (required): The refresh token associated with the user's session.
10
+ - `anti_csrf_token` (optional): A token for client-side CSRF protection.
11
+
12
+ ### Example Usage
13
+
14
+ ```ruby
15
+ client = SignInService::Client.new
16
+ refresh_token refresh_token = "your_refresh_token"
17
+ anti_csrf_token = "your_anti_csrf_token" (optional)
18
+
19
+ response = client.revoke_token(refresh_token: refresh_token, anti_csrf_token: anti_csrf_token)
20
+ ```
21
+
22
+ ## Handling Responses
23
+ The `revoke_token` method returns a Faraday::Response object. If successful, the response contains an HTTP status code of 200 and an empty body.
@@ -0,0 +1,48 @@
1
+ # Token
2
+ The Token Endpoint is responsible for exchanging an authorization code for session tokens.
3
+
4
+ ## Usage
5
+
6
+ ### Parameters
7
+
8
+ - `code` (required): The code received from the authorize callback.
9
+ - `code_verifier` (required): The string stored client-side from the code_challenge.
10
+
11
+ ### Example
12
+
13
+ ```ruby
14
+ client = SignInService::Client.new
15
+
16
+ # Assuming you have a valid authorization code and code_verifier
17
+ code = "your_authorization_code"
18
+ code_verifier = "your_code_verifier"
19
+
20
+ response = client.get_token(code: code, code_verifier: code_verifier)
21
+ ```
22
+
23
+ ## Handling Responses
24
+ The `get_token` method returns a Faraday::Response object.
25
+
26
+ ### `:cookie` auth type
27
+ Tokens are returned in the `Set-Cookie` headers
28
+
29
+ ```ruby
30
+ response.headers['set-cookie']
31
+
32
+ # vagov_access_token=..., vagov_refresh_token=..., vagov_anti_csrf_token=..., vagov_info_token=...
33
+ ```
34
+
35
+ ### `:api` auth type
36
+ Tokens are returned in the response body
37
+
38
+ ```ruby
39
+ JSON.parse(response.body)
40
+
41
+ # {
42
+ # "data": {
43
+ # "access_token": "<accessTokenHash>", // JWT eyJhbGci0... etc
44
+ # "refresh_token": "<refreshTokenHash>", // v1:secure+data+AZX9...
45
+ # "anti_csrf_token": "<antiCsrfTokenHash>" // be5aac9...
46
+ # }
47
+ # }
48
+ ```
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Client
5
+ module Authorize
6
+ AUTHORIZE_PATH = '/v0/sign_in/authorize'
7
+ TOKEN_PATH = '/v0/sign_in/token'
8
+
9
+ ##
10
+ # Generates an authorization URI.
11
+ # It is used in the OAuth 2.0 authorization code flow.
12
+ #
13
+ # @param type [String] The CSP type
14
+ # @param acr [String] Level of authentication requested
15
+ # @param code_challenge [String] Used by SiS to verify requests
16
+ # @param state [String] Optional string that can be returned in callback
17
+ #
18
+ # @return [String] URI to authorize client
19
+ #
20
+
21
+ def authorize_uri(type:, acr:, code_challenge: nil, state: nil)
22
+ uri = URI.join(base_url, AUTHORIZE_PATH)
23
+ params = {
24
+ acr:,
25
+ client_id:,
26
+ code_challenge:,
27
+ code_challenge_method:,
28
+ state:,
29
+ type:
30
+ }.compact
31
+
32
+ uri.query = URI.encode_www_form(params)
33
+
34
+ uri.to_s
35
+ end
36
+
37
+ ##
38
+ # Makes call to authorize path.
39
+ # For :api auth_type applications
40
+ #
41
+ # @param type [String] The CSP type
42
+ # @param acr [String] Level of authentication requested
43
+ # @param code_challenge [String] Used by SiS to verify requests
44
+ # @param state [String] Optional string that can be returned in callback
45
+ #
46
+ # @return [Faraday::Response] Contains 'code' parameter used to exchange for token
47
+ #
48
+ def authorize(type:, acr:, code_challenge:, state: nil)
49
+ params = {
50
+ acr:,
51
+ client_id:,
52
+ code_challenge:,
53
+ code_challenge_method:,
54
+ state:,
55
+ type:
56
+ }.compact
57
+
58
+ connection.get(AUTHORIZE_PATH, params)
59
+ end
60
+
61
+ ##
62
+ # Exchange code for session tokens
63
+ #
64
+ # @param code [String] The code received form the authorize callback
65
+ # @param code_verifier [String] String stored client side from code_challenge
66
+ #
67
+ # @return [Faraday::Response] Response with tokens in header or body
68
+ #
69
+ def get_token(code:, code_verifier: nil, client_assertion: nil)
70
+ params = {
71
+ code:,
72
+ code_verifier:,
73
+ grant_type:,
74
+ client_assertion:
75
+ }.compact
76
+
77
+ params[:client_assertion_type] = client_assertion_type unless client_assertion.nil?
78
+
79
+ connection.post(TOKEN_PATH, params)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Client
5
+ module Config
6
+ class << self
7
+ DEFAULT_BASE_URL = 'http://localhost:3000'
8
+ DEFAULT_CLIENT_ID = 'sample'
9
+ DEFAULT_AUTH_TYPE = :cookie
10
+ DEFAULT_AUTH_FLOW = :pkce
11
+
12
+ attr_writer :base_url, :client_id, :auth_type, :auth_flow
13
+
14
+ def base_url
15
+ @base_url || DEFAULT_BASE_URL
16
+ end
17
+
18
+ def client_id
19
+ @client_id || DEFAULT_CLIENT_ID
20
+ end
21
+
22
+ def auth_type
23
+ @auth_type || DEFAULT_AUTH_TYPE
24
+ end
25
+
26
+ def auth_flow
27
+ @auth_flow || DEFAULT_AUTH_FLOW
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ base_url:,
33
+ client_id:,
34
+ auth_type:,
35
+ auth_flow:
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Client
5
+ module Session
6
+ LOGOUT_PATH = '/v0/sign_in/logout'
7
+ REFRESH_PATH = '/v0/sign_in/refresh'
8
+ REVOKE_PATH = '/v0/sign_in/revoke'
9
+ REVOKE_ALL_PATH = '/v0/sign_in/revoke_all_sessions'
10
+
11
+ ##
12
+ # Destroys the user session associated with the access token.
13
+ #
14
+ # @param access_token [String] Access token of session to logout of
15
+ # @param anti_csrf_token [String] Optional token if enabled on client
16
+ #
17
+ # @return [Faraday::Response] Empty body with a 200 status
18
+ #
19
+ def logout(access_token:, anti_csrf_token: nil)
20
+ connection.get(LOGOUT_PATH) do |req|
21
+ req.params[:client_id] = client_id
22
+ if cookie_auth?
23
+ req.headers = cookie_header({ access_token:, anti_csrf_token: })
24
+ else
25
+ req.params[:anti_csrf_token] = anti_csrf_token
26
+ req.headers = api_header(access_token)
27
+ end
28
+ end
29
+ end
30
+
31
+ ##
32
+ # Refresh session tokens
33
+ #
34
+ # @param refresh_token [String] URI-encoded refresh token
35
+ # @param anti_csrf_token [String] Optional token if enabled on client
36
+ #
37
+ # @return [Faraday::Response] Response with tokens in header or body
38
+ #
39
+ def refresh_token(refresh_token:, anti_csrf_token: nil)
40
+ connection.post(REFRESH_PATH) do |req|
41
+ if cookie_auth?
42
+ req.headers = cookie_header({ refresh_token:, anti_csrf_token: })
43
+ else
44
+ req.params[:refresh_token] = refresh_token
45
+ req.params[:anti_csrf_token] = anti_csrf_token if anti_csrf_token
46
+ end
47
+ end
48
+ end
49
+
50
+ ##
51
+ # Revokes a user session
52
+ #
53
+ # @param refresh_token [String] URI-encoded refresh token
54
+ # @param anti_csrf_token [String] Optional token if enabled on client
55
+ #
56
+ # @return [Faraday::Response] Empty body with a 200 status
57
+ #
58
+ def revoke_token(refresh_token:, anti_csrf_token:)
59
+ connection.post(REVOKE_PATH) do |req|
60
+ req.params[:refresh_token] = CGI.escape(refresh_token)
61
+ req.params[:anti_csrf_token] = CGI.escape(anti_csrf_token)
62
+ end
63
+ end
64
+
65
+ ##
66
+ # Revokes all sessions associated with a user
67
+ #
68
+ # @param access_token [String] Access token of session
69
+ #
70
+ # @return [Faraday::Response] Empty body with a 200 status
71
+ #
72
+ def revoke_all_sessions(access_token:)
73
+ connection.get(REVOKE_ALL_PATH) do |req|
74
+ req.headers = if cookie_auth?
75
+ cookie_header({ access_token: })
76
+ else
77
+ api_header(access_token)
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def cookie_header(tokens)
85
+ {
86
+ Cookie: tokens.map do |name, value|
87
+ "#{COOKIE_TOKEN_PREFIX}_#{name}=#{CGI.escape(value)}" unless value.nil?
88
+ end.join(';')
89
+ }
90
+ end
91
+
92
+ def api_header(access_token)
93
+ {
94
+ Authorization: "Bearer #{CGI.escape(access_token)}"
95
+ }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ require_relative 'client/authorize'
6
+ require_relative 'client/session'
7
+ require_relative 'client/config'
8
+ require_relative 'response/raise_error'
9
+
10
+ module SignInService
11
+ class Client
12
+ include SignInService::Client::Config
13
+ include SignInService::Client::Authorize
14
+ include SignInService::Client::Session
15
+
16
+ COOKIE_TOKEN_PREFIX = 'vagov'
17
+ AUTH_TYPES = [COOKIE_AUTH = :cookie, API_AUTH = :api].freeze
18
+ AUTH_FLOWS = [PKCE_FLOW = :pkce, JWT_FLOW = :jwt].freeze
19
+
20
+ class << self
21
+ def configure
22
+ yield Config
23
+ end
24
+
25
+ def config
26
+ Config
27
+ end
28
+ end
29
+
30
+ attr_accessor :base_url, :client_id, :auth_type, :auth_flow
31
+
32
+ def initialize(**options)
33
+ @base_url = options[:base_url] || Config.base_url
34
+ @client_id = options[:client_id] || Config.client_id
35
+ @auth_type = options[:auth_type] || Config.auth_type
36
+ @auth_flow = options[:auth_flow] || Config.auth_flow
37
+ end
38
+
39
+ def grant_type
40
+ 'authorization_code'
41
+ end
42
+
43
+ def code_challenge_method
44
+ 'S256'
45
+ end
46
+
47
+ def client_assertion_type
48
+ 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
49
+ end
50
+
51
+ def connection
52
+ @connection ||= Faraday.new(base_url) do |conn|
53
+ conn.request :json
54
+ conn.response :json, content_type: /\bjson$/
55
+ conn.adapter Faraday.default_adapter
56
+ conn.use SignInService::Response::RaiseError
57
+ end
58
+ end
59
+
60
+ def api_auth?
61
+ auth_type.to_sym == API_AUTH
62
+ end
63
+
64
+ def cookie_auth?
65
+ auth_type.to_sym == COOKIE_AUTH
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Error < StandardError
5
+ attr_reader :response, :response_headers, :response_body, :errors
6
+
7
+ def self.from_response(response)
8
+ status = response[:status].to_i
9
+
10
+ klass = case status
11
+ when 400 then BadRequest
12
+ when 401 then Unauthorized
13
+ when 403 then Forbidden
14
+ when 404 then NotFound
15
+ when 422 then UnprocessableEntity
16
+ when 400..499 then ClientError
17
+ when 500 then InternalServerError
18
+ when 501 then NotImplemented
19
+ when 502 then BadGateway
20
+ when 503 then ServiceUnavailable
21
+ when 500..599 then ServerError
22
+ end
23
+
24
+ klass&.new(response)
25
+ end
26
+
27
+ def initialize(response)
28
+ @response = response
29
+ @response_headers = response[:response_headers]
30
+ @response_body = parsed_response_body
31
+ @errors = fetch_errors
32
+ super(error_message)
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_errors
38
+ case parsed_response_body
39
+ when Hash
40
+ parsed_response_body[:errors]
41
+ when String
42
+ parsed_response_body
43
+ end
44
+ end
45
+
46
+ def error_message
47
+ case errors
48
+ when Hash
49
+ format_errors(errors)
50
+ when String
51
+ errors
52
+ end
53
+ end
54
+
55
+ def format_errors(errors)
56
+ case errors
57
+ when Hash
58
+ errors.flat_map do |key, values|
59
+ values.map { |message| "#{key} #{message}" }
60
+ end.join(', ')
61
+ when Array
62
+ errors.join(', ')
63
+ when String
64
+ errors
65
+ end
66
+ end
67
+
68
+ def parsed_response_body
69
+ @parsed_response_body ||= if response_headers && response_headers['content-type'] =~ /json/
70
+ begin
71
+ JSON.parse(response[:body], symbolize_names: true)
72
+ rescue JSON::ParserError
73
+ {}
74
+ end
75
+ else
76
+ response[:body]
77
+ end
78
+ end
79
+ end
80
+
81
+ # Raised on errors in the 400-499 range
82
+ class ClientError < Error; end
83
+
84
+ # Raised when SignInService returns a 400 HTTP status code
85
+ class BadRequest < ClientError; end
86
+
87
+ # Raised when SignInService returns a 401 HTTP status code
88
+ class Unauthorized < ClientError; end
89
+
90
+ # Raised when SignInService returns a 403 HTTP status code
91
+ class Forbidden < ClientError; end
92
+
93
+ # Raised when SignInService returns a 404 HTTP status code
94
+ class NotFound < ClientError; end
95
+
96
+ # Raised when SignInService returns a 422 HTTP status code
97
+ class UnprocessableEntity < ClientError; end
98
+
99
+ # Raised on SignInService in the 500-599 range
100
+ class ServerError < Error; end
101
+
102
+ # Raised when SignInService returns a 500 HTTP status code
103
+ class InternalServerError < ServerError; end
104
+
105
+ # Raised when SignInService returns a 501 HTTP status code
106
+ class NotImplemented < ServerError; end
107
+
108
+ # Raised when SignInService returns a 502 HTTP status code
109
+ class BadGateway < ServerError; end
110
+
111
+ # Raised when SignInService returns a 503 HTTP status code
112
+ class ServiceUnavailable < ServerError; end
113
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'sign_in_service/error'
5
+
6
+ module SignInService
7
+ module Response
8
+ class RaiseError < Faraday::Middleware
9
+ def on_complete(response)
10
+ return unless (error = SignInService::Error.from_response(response))
11
+
12
+ raise error
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Sts
5
+ module Config
6
+ class << self
7
+ DEFAULT_STS_BASE_URL = 'https://staging-api.va.gov/v0/sign_in'
8
+
9
+ attr_accessor :issuer, :scopes, :service_account_id, :user_attributes, :private_key_path
10
+
11
+ attr_writer :base_url
12
+
13
+ def base_url
14
+ @base_url || DEFAULT_STS_BASE_URL
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ class Sts
5
+ module Token
6
+ TOKEN_PATH = '/v0/sign_in/token'
7
+ ACCESS_TOKEN_DURATION = 300
8
+ JWT_ENCODE_ALGORITHM = 'RS256'
9
+
10
+ def token
11
+ params = { grant_type:, assertion: }
12
+ response = connection.post(TOKEN_PATH, params)
13
+
14
+ response.body.dig('data', 'access_token')
15
+ end
16
+
17
+ private
18
+
19
+ def assertion
20
+ JWT.encode(assertion_payload, private_key, JWT_ENCODE_ALGORITHM)
21
+ end
22
+
23
+ def assertion_payload
24
+ {
25
+ iss: issuer,
26
+ sub: user_identifier,
27
+ aud:,
28
+ iat:,
29
+ exp:,
30
+ jti:,
31
+ scopes:,
32
+ service_account_id:,
33
+ user_attributes:
34
+ }.compact
35
+ end
36
+
37
+ def aud
38
+ "#{base_url}#{TOKEN_PATH}"
39
+ end
40
+
41
+ def iat
42
+ @iat ||= Time.now.to_i
43
+ end
44
+
45
+ def exp
46
+ iat + ACCESS_TOKEN_DURATION
47
+ end
48
+
49
+ def jti
50
+ SecureRandom.hex
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'jwt'
5
+
6
+ require_relative 'sts/config'
7
+ require_relative 'sts/token'
8
+
9
+ module SignInService
10
+ class Sts
11
+ include Config
12
+ include Token
13
+
14
+ REQUIRED_ATTRIBUTES = %i[user_identifier issuer service_account_id private_key_path base_url].freeze
15
+
16
+ class << self
17
+ def configure
18
+ yield Config
19
+ end
20
+ end
21
+
22
+ attr_reader :user_identifier, :issuer, :scopes, :service_account_id,
23
+ :user_attributes, :private_key_path, :base_url
24
+
25
+ def initialize(user_identifier:, **options)
26
+ @user_identifier = user_identifier
27
+ @issuer = options[:issuer] || Config.issuer
28
+ @scopes = options[:scopes] || Config.scopes || []
29
+ @service_account_id = options[:service_account_id] || Config.service_account_id
30
+ @user_attributes = options[:user_attributes] || Config.user_attributes
31
+ @private_key_path = options[:private_key_path] || Config.private_key_path
32
+ @base_url = options[:base_url] || Config.base_url
33
+
34
+ validate_arguments!
35
+ end
36
+
37
+ private
38
+
39
+ def connection
40
+ @connection ||= Faraday.new(base_url) do |conn|
41
+ conn.adapter Faraday.default_adapter
42
+ conn.request :json
43
+ conn.response :json, content_type: /\bjson$/
44
+ conn.use SignInService::Response::RaiseError
45
+ end
46
+ end
47
+
48
+ def grant_type
49
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer'
50
+ end
51
+
52
+ def private_key
53
+ OpenSSL::PKey::RSA.new(File.read(private_key_path))
54
+ end
55
+
56
+ def validate_arguments!
57
+ REQUIRED_ATTRIBUTES.each do |attribute|
58
+ raise ArgumentError, "missing required attribute: #{attribute}" if send(attribute).nil?
59
+ end
60
+
61
+ raise ArgumentError, 'scopes must be an array' unless scopes.is_a?(Array)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignInService
4
+ VERSION = '0.4.0'
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sign_in_service/client'
4
+ require 'sign_in_service/sts'
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/sign_in_service/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'sign_in_service'
7
+ spec.version = SignInService::VERSION
8
+ spec.authors = ['Riley Anderson']
9
+ spec.email = ['riley.anderson@oddball.io']
10
+
11
+ spec.summary = 'Wrapper for the VA SignInService API'
12
+ spec.homepage = 'https://github.com/department-of-veterans-affairs/sign-in-service-rb'
13
+ spec.license = 'CC0-1.0'
14
+ spec.required_ruby_version = '>= 3.3'
15
+
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = 'https://github.com/department-of-veterans-affairs/sign-in-service-rb'
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
24
+ end
25
+ end
26
+ spec.bindir = 'exe'
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ['lib']
29
+
30
+ # Uncomment to register a new dependency of your gem
31
+ spec.add_dependency 'faraday', '~> 2.7'
32
+ spec.add_dependency 'jwt', '~> 2.8'
33
+
34
+ # For more information and examples about making a new gem, check out our
35
+ # guide at: https://bundler.io/guides/creating_gem.html
36
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sign_in_service
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Riley Anderson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-08-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.8'
41
+ description:
42
+ email:
43
+ - riley.anderson@oddball.io
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".rubocop.yml"
50
+ - ".ruby-version"
51
+ - Gemfile
52
+ - Gemfile.lock
53
+ - LICENSE.txt
54
+ - README.md
55
+ - Rakefile
56
+ - docs/endpoints/authorize.md
57
+ - docs/endpoints/logout.md
58
+ - docs/endpoints/refresh.md
59
+ - docs/endpoints/revoke_all_sessions.md
60
+ - docs/endpoints/revoke_token.md
61
+ - docs/endpoints/token.md
62
+ - lib/sign_in_service.rb
63
+ - lib/sign_in_service/client.rb
64
+ - lib/sign_in_service/client/authorize.rb
65
+ - lib/sign_in_service/client/config.rb
66
+ - lib/sign_in_service/client/session.rb
67
+ - lib/sign_in_service/error.rb
68
+ - lib/sign_in_service/response/raise_error.rb
69
+ - lib/sign_in_service/sts.rb
70
+ - lib/sign_in_service/sts/config.rb
71
+ - lib/sign_in_service/sts/token.rb
72
+ - lib/sign_in_service/version.rb
73
+ - sign_in_service.gemspec
74
+ homepage: https://github.com/department-of-veterans-affairs/sign-in-service-rb
75
+ licenses:
76
+ - CC0-1.0
77
+ metadata:
78
+ homepage_uri: https://github.com/department-of-veterans-affairs/sign-in-service-rb
79
+ source_code_uri: https://github.com/department-of-veterans-affairs/sign-in-service-rb
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '3.3'
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.5.11
96
+ signing_key:
97
+ specification_version: 4
98
+ summary: Wrapper for the VA SignInService API
99
+ test_files: []