├── .cfignore ├── .env.example ├── .github └── pull_request_template.md ├── .gitignore ├── .gitlab-ci.yml ├── .prettierrc.json ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── Makefile ├── README.md ├── Rakefile ├── app.rb ├── config.rb ├── config.ru ├── config ├── demo_sp.crt ├── demo_sp.key └── newrelic.yml ├── deploy └── activate ├── dockerfiles └── ci.Dockerfile ├── lib └── tasks │ └── write_deploy_json.rake ├── openid_configuration.rb ├── package.json ├── public ├── api │ └── .keep └── assets │ ├── css │ ├── fake.css │ └── sign-in.css │ ├── img │ └── GitHub-Mark-120px-plus.png │ └── js │ └── scope-element.js ├── spec ├── app_spec.rb ├── fixtures │ ├── idp.key │ └── idp.key.pub ├── js │ └── scope-element.spec.js ├── openid_configuration_spec.rb └── spec_helper.rb ├── views ├── attempts.erb ├── errors.erb ├── event.erb ├── failure_to_proof.erb ├── header.erb ├── index.erb └── layout.erb └── yarn.lock /.cfignore: -------------------------------------------------------------------------------- 1 | .env 2 | config/application.yml 3 | 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | export acr_values=http://idmanagement.gov/ns/assurance/ial/1 2 | export client_id=urn:gov:gsa:openidconnect:sp:sinatra 3 | export client_id_pkce=urn:gov:gsa:openidconnect:sp:sinatra_pkce 4 | export redirect_uri=http://localhost:9292/ 5 | export sp_private_key_path=./config/demo_sp.key 6 | export idp_domain=localhost:3000 7 | export idp_url=http://localhost:3000 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | This repo has been moved! Please [open a merge request on GitLab](https://gitlab.login.gov/lg/identity-oidc-sinatra/-/merge_requests/new) 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | /config/application.yml 8 | /coverage 9 | .byebug_history 10 | /log 11 | .env 12 | public/api/deploy.json 13 | public/vendor 14 | node_modules 15 | .idea 16 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Jobs defined here use the idp/ci docker image from ECR by default. 2 | # Images are built via the identity-devops GitLab pipeline. 3 | 4 | variables: 5 | BUNDLER_VERSION: "2.3.13" 6 | ECR_REGISTRY: '${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com' 7 | OIDC_SINATRA_CI_SHA: 'sha256:1e40d3913f526c8cef1e68474a2b0ecc8da71c303d3be9a38d5fc94c3e9b08b0' 8 | 9 | default: 10 | image: '${ECR_REGISTRY}/oidc_sinatra/ci@${OIDC_SINATRA_CI_SHA}' 11 | 12 | 13 | .bundle_install: &bundle_install 14 | - gem install bundler --version $BUNDLER_VERSION 15 | - bundle check || bundle install --deployment --jobs=4 --retry=3 --without deploy development doc production --path vendor/bundle 16 | 17 | .yarn_install: &yarn_install 18 | - yarn install --frozen-lockfile --ignore-engines --cache-folder .yarn-cache 19 | 20 | .yarn_production_install: &yarn_production_install 21 | - yarn install --production --frozen-lockfile --ignore-engines --cache-folder .yarn-cache 22 | 23 | 24 | .deploy_script: &deploy_script 25 | - *bundle_install 26 | - *yarn_install 27 | - bundle exec rake login:deploy_json 28 | - make copy_vendor 29 | - cf8 login -a https://api.fr.cloud.gov -u "e1fdd211-f191-40e8-99c7-4e7164d9ae76" -p $CF8_PASS -o "gsa-login-prototyping" -s "$SPACE" 30 | - cf8 push $SPACE-identity-oidc-sinatra -b ruby_buildpack -s cflinuxfs4 31 | 32 | .build_cache: 33 | - &ruby_cache 34 | key: 35 | files: 36 | - Gemfile.lock 37 | paths: 38 | - vendor/bundle 39 | policy: pull 40 | 41 | - &yarn_cache 42 | key: 43 | files: 44 | - yarn.lock 45 | paths: 46 | - .yarn-cache/ 47 | policy: pull 48 | 49 | - &yarn_production_cache 50 | key: 51 | files: 52 | - yarn.lock 53 | paths: 54 | - .yarn-cache/ 55 | policy: pull 56 | 57 | stages: 58 | - .pre 59 | - test 60 | - deploy 61 | 62 | workflow: 63 | rules: 64 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "web"' 65 | - if: '$CI_COMMIT_BRANCH == "main"' 66 | - if: $CI_COMMIT_TAG 67 | when: never 68 | 69 | install_dependencies: 70 | stage: .pre 71 | variables: 72 | RAILS_ENV: test 73 | SKIP_YARN_INSTALL: 'true' 74 | cache: 75 | - <<: *ruby_cache 76 | policy: pull-push 77 | - <<: *yarn_cache 78 | policy: pull-push 79 | script: 80 | - *bundle_install 81 | - *yarn_install 82 | - bundle exec rake login:deploy_json 83 | 84 | test_release: 85 | stage: test 86 | needs: 87 | - job: install_dependencies 88 | cache: 89 | - <<: *ruby_cache 90 | - <<: *yarn_cache 91 | script: 92 | - *bundle_install 93 | - *yarn_install 94 | - make .env 95 | - make check 96 | artifacts: 97 | paths: 98 | - /tmp/test-results 99 | 100 | build-ci-image: 101 | stage: .pre 102 | interruptible: true 103 | needs: [] 104 | tags: 105 | - build-pool 106 | image: 107 | name: gcr.io/kaniko-project/executor:debug 108 | entrypoint: [''] 109 | rules: 110 | # Build when there are changes to the Dockerfile 111 | - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "web"' 112 | changes: 113 | compare_to: 'refs/heads/main' 114 | paths: 115 | - dockerfiles/ci.Dockerfile 116 | script: 117 | - mkdir -p /kaniko/.docker 118 | - |- 119 | KANIKOCFG="\"credsStore\":\"ecr-login\"" 120 | if [ "x${http_proxy}" != "x" -o "x${https_proxy}" != "x" ]; then 121 | KANIKOCFG="${KANIKOCFG}, \"proxies\": { \"default\": { \"httpProxy\": \"${http_proxy}\", \"httpsProxy\": \"${https_proxy}\", \"noProxy\": \"${no_proxy}\"}}" 122 | fi 123 | KANIKOCFG="{ ${KANIKOCFG} }" 124 | echo "${KANIKOCFG}" > /kaniko/.docker/config.json 125 | - >- 126 | /kaniko/executor 127 | --context "${CI_PROJECT_DIR}" 128 | --dockerfile "${CI_PROJECT_DIR}/dockerfiles/ci.Dockerfile" 129 | --destination "${ECR_REGISTRY}/oidc_sinatra/ci:latest" 130 | --destination "${ECR_REGISTRY}/oidc_sinatra/ci:${CI_COMMIT_SHA}" 131 | --build-arg "http_proxy=${http_proxy}" --build-arg "https_proxy=${https_proxy}" --build-arg "no_proxy=${no_proxy}" 132 | 133 | deploy_to_cloudgov: 134 | stage: deploy 135 | rules: 136 | - if: '$CI_COMMIT_BRANCH == "main"' 137 | script: *deploy_script 138 | parallel: 139 | matrix: 140 | - SPACE: [prod, staging, int, dev, dm] 141 | 142 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { "singleQuote": true, "printWidth": 100 } 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - 'node_modules/**/*' 4 | - 'public/**/*' 5 | - 'vendor/**/*' 6 | TargetRubyVersion: 3.2.2 7 | UseCache: true 8 | DisabledByDefault: true 9 | SuggestExtensions: false 10 | 11 | Layout/ParameterAlignment: 12 | Description: >- 13 | Align the parameters of a method call if they span more 14 | than one line. 15 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-double-indent' 16 | EnforcedStyle: with_first_parameter 17 | SupportedStyles: 18 | - with_first_parameter 19 | - with_fixed_indentation 20 | IndentationWidth: ~ 21 | 22 | Layout/DotPosition: 23 | Description: Checks the position of the dot in multi-line method calls. 24 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains 25 | EnforcedStyle: trailing 26 | SupportedStyles: 27 | - leading 28 | - trailing 29 | 30 | Layout/ExtraSpacing: 31 | AllowForAlignment: true 32 | ForceEqualSignAlignment: false 33 | 34 | # empty lines are fine 35 | Layout/EmptyLinesAroundBlockBody: 36 | Enabled: false 37 | Layout/EmptyLinesAroundClassBody: 38 | Enabled: false 39 | Layout/EmptyLinesAroundExceptionHandlingKeywords: 40 | Enabled: false 41 | Layout/EmptyLinesAroundModuleBody: 42 | Enabled: false 43 | Layout/EmptyLines: 44 | Enabled: false 45 | 46 | Layout/FirstArrayElementIndentation: 47 | EnforcedStyle: special_inside_parentheses 48 | SupportedStyles: 49 | - special_inside_parentheses 50 | - consistent 51 | - align_brackets 52 | IndentationWidth: ~ 53 | 54 | Layout/MultilineOperationIndentation: 55 | EnforcedStyle: aligned 56 | SupportedStyles: 57 | - aligned 58 | - indented 59 | IndentationWidth: ~ 60 | 61 | Layout/LineLength: 62 | Description: Limit lines to 100 characters. 63 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#80-character-limits 64 | Enabled: true 65 | Max: 100 66 | AllowURI: true 67 | URISchemes: 68 | - http 69 | - https 70 | Exclude: 71 | - 'spec/**/*' 72 | 73 | Style/AndOr: 74 | Description: Use &&/|| instead of and/or. 75 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#no-and-or-or 76 | EnforcedStyle: conditionals 77 | SupportedStyles: 78 | - always 79 | - conditionals 80 | 81 | Style/BlockDelimiters: 82 | Enabled: false 83 | # Prefer do...end for procedural blocks, {...} for functional 84 | #EnforcedStyle: semantic 85 | 86 | # for certain module hierarchies this is not useful 87 | Style/ClassAndModuleChildren: 88 | Enabled: false 89 | 90 | # This default recommendation is completely wrong 91 | Style/ConditionalAssignment: 92 | Enabled: false 93 | 94 | Style/Documentation: 95 | Description: Document classes and non-namespace modules. 96 | Enabled: false 97 | Exclude: 98 | - 'spec/**/*' 99 | 100 | Style/EmptyElse: 101 | EnforcedStyle: both 102 | SupportedStyles: 103 | - empty 104 | - nil 105 | - both 106 | 107 | Style/FrozenStringLiteralComment: 108 | Description: >- 109 | Add the frozen_string_literal comment to the top of files 110 | to help transition from Ruby 2.3.0 to Ruby 3.0. 111 | Enabled: false 112 | 113 | # Too many false positives to be useful 114 | Style/GuardClause: 115 | Enabled: false 116 | 117 | # Sometimes a `return` enhances clarity 118 | Style/RedundantReturn: 119 | Enabled: false 120 | 121 | # Very frequently if/unless as modifier reduces clarity 122 | Style/IfUnlessModifier: 123 | Enabled: false 124 | 125 | Style/RaiseArgs: 126 | EnforcedStyle: compact 127 | 128 | Style/StringLiterals: 129 | Description: Checks if uses of quotes match the configured preference. 130 | StyleGuide: https://github.com/bbatsov/ruby-style-guide#consistent-string-literals 131 | EnforcedStyle: single_quotes 132 | SupportedStyles: 133 | - single_quotes 134 | - double_quotes 135 | ConsistentQuotesInMultiline: true 136 | 137 | Style/TrailingCommaInArguments: 138 | EnforcedStyleForMultiline: comma 139 | SupportedStylesForMultiline: 140 | - comma 141 | - consistent_comma 142 | - no_comma 143 | 144 | # trailing commas improve diff clarity at no cost to readability 145 | Style/TrailingCommaInHashLiteral: 146 | EnforcedStyleForMultiline: consistent_comma 147 | Style/TrailingCommaInArrayLiteral: 148 | EnforcedStyleForMultiline: consistent_comma 149 | 150 | Style/SingleLineBlockParams: 151 | Enabled: false 152 | 153 | # All of these Metrics are mostly useless as an indicator of anything 154 | Metrics/AbcSize: 155 | Enabled: false 156 | Metrics/BlockLength: 157 | CountComments: false # count full line comments? 158 | Enabled: true 159 | Max: 25 160 | Exclude: 161 | - '**/bin/*' 162 | - 'spec/**/*.rb' 163 | Metrics/ClassLength: 164 | Enabled: false 165 | Metrics/CyclomaticComplexity: 166 | Enabled: false 167 | Metrics/MethodLength: 168 | Enabled: false 169 | Metrics/ModuleLength: 170 | Enabled: false 171 | Metrics/PerceivedComplexity: 172 | Enabled: false 173 | Metrics/ParameterLists: 174 | Enabled: false 175 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.4 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're so glad you're thinking about contributing to an 18F open source project! If you're unsure about anything, just ask -- or submit the issue or pull request anyway. The worst that can happen is you'll be politely asked to change something. We love all friendly contributions. 4 | 5 | We want to ensure a welcoming environment for all of our projects. Our staff follow the [18F Code of Conduct](https://github.com/18F/code-of-conduct/blob/master/code-of-conduct.md) and all contributors should do the same. 6 | 7 | We encourage you to read this project's CONTRIBUTING policy (you are here), its [LICENSE](LICENSE.md), and its [README](README.md). 8 | 9 | If you have any questions or want to read more, check out the [18F Open Source Policy GitHub repository](https://github.com/18f/open-source-policy), or just [shoot us an email](mailto:18f@gsa.gov). 10 | 11 | ## Public domain 12 | 13 | This project is in the public domain within the United States, and 14 | copyright and related rights in the work worldwide are waived through 15 | the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 16 | 17 | All contributions to this project will be released under the CC0 18 | dedication. By submitting a pull request, you are agreeing to comply 19 | with this waiver of copyright interest. 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | 3 | FROM ruby:3.3.4 4 | 5 | WORKDIR /code 6 | COPY . /code 7 | 8 | RUN apt-get update && apt-get upgrade -y && apt-get install -y yarnpkg 9 | RUN mkdir -p public/vendor 10 | RUN cp .env.example .env 11 | RUN bundle install 12 | RUN yarnpkg install 13 | RUN cp -R node_modules/@uswds/uswds/dist public/vendor/uswds 14 | 15 | 16 | EXPOSE 9292 17 | 18 | CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "9292"] 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 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 | ruby "~> #{File.read(File.join(__dir__, '.ruby-version')).strip}" 8 | 9 | gem 'aws-sdk-secretsmanager', '~> 1.21' 10 | gem 'dotenv' 11 | gem 'erubi', '~> 1.8' 12 | gem 'faraday' 13 | gem 'json-jwt', '~> 1.16.6' 14 | gem 'jwe' 15 | gem 'jwt', '~> 2.1' 16 | gem 'nokogiri', '>= 1.11.0' 17 | gem 'puma', '~> 5.6' 18 | gem 'rake' 19 | gem 'sinatra', '~> 2.2' 20 | gem 'newrelic_rpm' 21 | 22 | group :development do 23 | gem 'pry-byebug' 24 | end 25 | 26 | group :test do 27 | gem 'fakefs', require: 'fakefs/safe' 28 | gem 'rack-test', '>= 1.1.0' 29 | gem 'rspec', '~> 3.11' 30 | gem 'simplecov', require: false 31 | gem 'webmock' 32 | end 33 | 34 | group :development, :test do 35 | gem 'byebug' 36 | gem 'rubocop', require: false 37 | gem 'rubocop-rspec', require: false 38 | end 39 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (7.1.3.2) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | minitest (>= 5.1) 12 | mutex_m 13 | tzinfo (~> 2.0) 14 | addressable (2.8.1) 15 | public_suffix (>= 2.0.2, < 6.0) 16 | aes_key_wrap (1.1.0) 17 | ast (2.4.2) 18 | aws-eventstream (1.2.0) 19 | aws-partitions (1.627.0) 20 | aws-sdk-core (3.143.0) 21 | aws-eventstream (~> 1, >= 1.0.2) 22 | aws-partitions (~> 1, >= 1.525.0) 23 | aws-sigv4 (~> 1.1) 24 | jmespath (~> 1, >= 1.6.1) 25 | aws-sdk-secretsmanager (1.65.0) 26 | aws-sdk-core (~> 3, >= 3.127.0) 27 | aws-sigv4 (~> 1.1) 28 | aws-sigv4 (1.5.1) 29 | aws-eventstream (~> 1, >= 1.0.2) 30 | base64 (0.2.0) 31 | bigdecimal (3.1.6) 32 | bindata (2.5.0) 33 | byebug (11.1.3) 34 | coderay (1.1.3) 35 | concurrent-ruby (1.2.3) 36 | connection_pool (2.4.1) 37 | crack (0.4.5) 38 | rexml 39 | diff-lcs (1.5.0) 40 | docile (1.4.1) 41 | dotenv (2.8.1) 42 | drb (2.2.1) 43 | erubi (1.11.0) 44 | fakefs (1.8.0) 45 | faraday (2.5.2) 46 | faraday-net_http (>= 2.0, < 3.1) 47 | ruby2_keywords (>= 0.0.4) 48 | faraday-follow_redirects (0.3.0) 49 | faraday (>= 1, < 3) 50 | faraday-net_http (3.0.0) 51 | hashdiff (1.0.1) 52 | i18n (1.14.1) 53 | concurrent-ruby (~> 1.0) 54 | jmespath (1.6.1) 55 | json (2.6.2) 56 | json-jwt (1.16.6) 57 | activesupport (>= 4.2) 58 | aes_key_wrap 59 | base64 60 | bindata 61 | faraday (~> 2.0) 62 | faraday-follow_redirects 63 | jwe (1.0.0) 64 | base64 65 | jwt (2.5.0) 66 | method_source (1.0.0) 67 | mini_portile2 (2.8.5) 68 | minitest (5.22.2) 69 | mustermann (2.0.2) 70 | ruby2_keywords (~> 0.0.1) 71 | mutex_m (0.2.0) 72 | newrelic_rpm (8.12.0) 73 | nio4r (2.7.0) 74 | nokogiri (1.16.2) 75 | mini_portile2 (~> 2.8.2) 76 | racc (~> 1.4) 77 | parallel (1.22.1) 78 | parser (3.1.2.1) 79 | ast (~> 2.4.1) 80 | pry (0.14.1) 81 | coderay (~> 1.1) 82 | method_source (~> 1.0) 83 | pry-byebug (3.10.1) 84 | byebug (~> 11.0) 85 | pry (>= 0.13, < 0.15) 86 | public_suffix (5.0.0) 87 | puma (5.6.8) 88 | nio4r (~> 2.0) 89 | racc (1.7.3) 90 | rack (2.2.8.1) 91 | rack-protection (2.2.3) 92 | rack 93 | rack-test (2.0.2) 94 | rack (>= 1.3) 95 | rainbow (3.1.1) 96 | rake (13.0.6) 97 | regexp_parser (2.6.0) 98 | rexml (3.2.5) 99 | rspec (3.11.0) 100 | rspec-core (~> 3.11.0) 101 | rspec-expectations (~> 3.11.0) 102 | rspec-mocks (~> 3.11.0) 103 | rspec-core (3.11.0) 104 | rspec-support (~> 3.11.0) 105 | rspec-expectations (3.11.0) 106 | diff-lcs (>= 1.2.0, < 2.0) 107 | rspec-support (~> 3.11.0) 108 | rspec-mocks (3.11.1) 109 | diff-lcs (>= 1.2.0, < 2.0) 110 | rspec-support (~> 3.11.0) 111 | rspec-support (3.11.0) 112 | rubocop (1.37.0) 113 | json (~> 2.3) 114 | parallel (~> 1.10) 115 | parser (>= 3.1.2.1) 116 | rainbow (>= 2.2.2, < 4.0) 117 | regexp_parser (>= 1.8, < 3.0) 118 | rexml (>= 3.2.5, < 4.0) 119 | rubocop-ast (>= 1.22.0, < 2.0) 120 | ruby-progressbar (~> 1.7) 121 | unicode-display_width (>= 1.4.0, < 3.0) 122 | rubocop-ast (1.23.0) 123 | parser (>= 3.1.1.0) 124 | rubocop-rspec (2.13.2) 125 | rubocop (~> 1.33) 126 | ruby-progressbar (1.11.0) 127 | ruby2_keywords (0.0.5) 128 | simplecov (0.22.0) 129 | docile (~> 1.1) 130 | simplecov-html (~> 0.11) 131 | simplecov_json_formatter (~> 0.1) 132 | simplecov-html (0.13.1) 133 | simplecov_json_formatter (0.1.4) 134 | sinatra (2.2.3) 135 | mustermann (~> 2.0) 136 | rack (~> 2.2) 137 | rack-protection (= 2.2.3) 138 | tilt (~> 2.0) 139 | tilt (2.0.11) 140 | tzinfo (2.0.6) 141 | concurrent-ruby (~> 1.0) 142 | unicode-display_width (2.3.0) 143 | webmock (3.18.1) 144 | addressable (>= 2.8.0) 145 | crack (>= 0.3.2) 146 | hashdiff (>= 0.4.0, < 2.0.0) 147 | 148 | PLATFORMS 149 | ruby 150 | 151 | DEPENDENCIES 152 | aws-sdk-secretsmanager (~> 1.21) 153 | byebug 154 | dotenv 155 | erubi (~> 1.8) 156 | fakefs 157 | faraday 158 | json-jwt (~> 1.16.6) 159 | jwe 160 | jwt (~> 2.1) 161 | newrelic_rpm 162 | nokogiri (>= 1.11.0) 163 | pry-byebug 164 | puma (~> 5.6) 165 | rack-test (>= 1.1.0) 166 | rake 167 | rspec (~> 3.11) 168 | rubocop 169 | rubocop-rspec 170 | simplecov 171 | sinatra (~> 2.2) 172 | webmock 173 | 174 | RUBY VERSION 175 | ruby 3.3.4p94 176 | 177 | BUNDLED WITH 178 | 2.6.5 179 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal summary 8 | 9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to 14 | the public domain by waiving all rights to the work worldwide 15 | under copyright law, including all related and neighboring rights, to the 16 | extent allowed by law. 17 | 18 | You can copy, modify, distribute and perform the work, even for commercial 19 | purposes, all without asking permission. 20 | 21 | ### Other information 22 | 23 | In no way are the patent or trademark rights of any person affected by CC0, 24 | nor are the rights that other persons may have in the work or in how the 25 | work is used, such as publicity or privacy rights. 26 | 27 | Unless expressly stated otherwise, the person who associated a work with 28 | this deed makes no warranties about the work, and disclaims liability for 29 | all uses of the work, to the fullest extent permitted by applicable law. 30 | When using or citing the work, you should not imply endorsement by the 31 | author or the affirmer. 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for building and running the project. 2 | # The purpose of this Makefile is to avoid developers having to remember 3 | # project-specific commands for building, running, etc. Recipes longer 4 | # than one or two lines should live in script files of their own in the 5 | # bin/ directory. 6 | 7 | HOST ?= localhost 8 | PORT ?= 9292 9 | 10 | all: check 11 | 12 | .env: 13 | cp .env.example .env 14 | 15 | public/vendor: 16 | mkdir -p public/vendor 17 | 18 | install_dependencies: 19 | bundle check || bundle install 20 | yarn install 21 | 22 | copy_vendor: public/vendor 23 | cp -R node_modules/@uswds/uswds/dist public/vendor/uswds 24 | 25 | setup: .env install_dependencies copy_vendor 26 | 27 | check: lint test 28 | 29 | lint: 30 | @echo "--- rubocop ---" 31 | bundle exec rubocop 32 | 33 | run: 34 | bundle exec rackup -p $(PORT) --host ${HOST} 35 | 36 | test: $(CONFIG) 37 | bundle exec rspec 38 | yarn test 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # identity-openidconnect-sinatra 2 | 3 | An example of a Relying Party for OpenID Connect written as a simple Sinatra app in Ruby. 4 | 5 | ## Running locally 6 | 7 | 1. Set up the environment with: 8 | 9 | ``` 10 | $ make setup 11 | ``` 12 | 13 | 2. And run the app server: 14 | 15 | ``` 16 | $ make run 17 | ``` 18 | 19 | 3. To run specs: 20 | 21 | ``` 22 | $ make test 23 | ``` 24 | 25 | ## Configuring 26 | 27 | 1. This sample service provider is configured to run on http://localhost:9292 by default. Optionally, you can assign a custom hostname or port by passing `HOST=` or `PORT=` environment variables when starting the application server. However, when you do this, you also have to make corresponding changes to the `redirect_uri` environment variable and also configure the identity provider appropriately. 28 | 29 | 2. Some other key environment variables that affect configuration: 30 | 31 | | Environment Variable | Description | Default | 32 | |-----------------------------|----------------------------------------------------------------------------------------------|-------------------------------------------| 33 | | client_id | Identifier for this app as configured with the identity provider. Used unless `PKCE` is true | urn:gov:gsa:openidconnect:sp:sinatra | 34 | | client_id_pkce | Identifier for this app as configured with the identity provider. Used if `PKCE` is true | urn:gov:gsa:openidconnect:sp:sinatra_pkce | 35 | | eipp_allowed | Enhanced In Person Proofing allowed | false | 36 | | idp_url | URL for the identity provider | http://localhost:3000 | 37 | | PKCE | Determines if PKCE or private_key_jwt is used to communicate with the identity provider | false | 38 | | semantic_ial_values_enabled | Determines if semantic IAL values can be used in `acr_values` | fals | 39 | | vtr_disabled | Vectors of Trust disabled | false | 40 | 41 | ## Contributing 42 | 43 | See [CONTRIBUTING](CONTRIBUTING.md) for additional information. 44 | 45 | ## Public domain 46 | 47 | This project is in the worldwide [public domain](LICENSE.md). As stated in [CONTRIBUTING](CONTRIBUTING.md): 48 | 49 | > This project is in the public domain within the United States, and copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 50 | > 51 | > All contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest. 52 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require './app' 2 | Dir.glob('lib/tasks/*.rake').each { |r| load r } 3 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotenv/load' 4 | require 'active_support/core_ext/hash/indifferent_access' 5 | require 'active_support/core_ext/object/to_query' 6 | require 'erubi' 7 | require 'faraday' 8 | require 'json' 9 | require 'json/jwt' 10 | require 'jwe' 11 | require 'jwt' 12 | require 'openssl' 13 | require 'securerandom' 14 | require 'sinatra/base' 15 | require 'time' 16 | require 'logger' 17 | if ENV['NEW_RELIC_LICENSE_KEY'] && ENV['NEW_RELIC_APP_NAME'] 18 | require 'newrelic_rpm' 19 | puts 'enabling newrelic' 20 | end 21 | 22 | require_relative './config' 23 | require_relative './openid_configuration' 24 | 25 | module LoginGov::OidcSinatra 26 | JWT_CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' 27 | 28 | class AppError < StandardError; end 29 | 30 | class OpenidConnectRelyingParty < Sinatra::Base 31 | set :erb, escape_html: true 32 | set :logger, proc { Logger.new(ENV['RACK_ENV'] == 'test' ? nil : $stdout) } 33 | 34 | if ENV['ENABLE_LOGGING'] == 'true' 35 | enable :logging, :dump_errors, :raise_errors, :show_exceptions 36 | settings.logger.info('enabling logging') 37 | end 38 | 39 | enable :sessions 40 | use Rack::Protection 41 | use Rack::Protection::AuthenticityToken 42 | 43 | configure :development do 44 | require 'byebug' 45 | end 46 | 47 | # rubocop:disable Metrics/BlockLength 48 | helpers do 49 | def ial_select_options 50 | options = [ 51 | ['1', 'Authentication only (default)'], 52 | ['2', 'Identity-verified'], 53 | ['0', 'IALMax'], 54 | ['step-up', 'Step-up Flow'], 55 | ['facial-match-preferred', 'Facial Match Preferred (ACR)'], 56 | ['facial-match-required', 'Facial Match Required (ACR)'], 57 | ] 58 | 59 | if config.eipp_allowed? 60 | options << [ 61 | 'enhanced-ipp-required', 'Enhanced In-Person Proofing (Enabled in dev & staging only)', 62 | ] 63 | end 64 | 65 | if config.vtr_enabled? 66 | options.push ['facial-match-vot', 'Facial Match (VoT)'] 67 | end 68 | 69 | options 70 | end 71 | 72 | def scope_options 73 | # https://developers.login.gov/attributes/ 74 | %w[ 75 | openid 76 | sub 77 | email 78 | all_emails 79 | locale 80 | ial 81 | aal 82 | profile 83 | given_name 84 | family_name 85 | address 86 | phone 87 | birthdate 88 | social_security_number 89 | verified_at 90 | x509 91 | x509_issuer 92 | x509_subject 93 | x509_presented 94 | ] 95 | end 96 | 97 | def default_scopes_by_ial 98 | ial2_options = [ 99 | '2', 100 | 'facial-match-preferred', 101 | 'facial-match-required', 102 | 'facial-match-vot', 103 | 'enhanced-ipp-required', 104 | ] 105 | 106 | default_scopes_by_ial = { 107 | nil => %w[openid email x509], 108 | '0' => %w[openid email social_security_number x509], 109 | '1' => %w[openid email x509], 110 | } 111 | 112 | ial2_options.each do |ial2_option| 113 | default_scopes_by_ial[ial2_option] = %w[ 114 | openid 115 | email 116 | profile 117 | social_security_number 118 | phone 119 | address 120 | x509 121 | ] 122 | end 123 | 124 | default_scopes_by_ial 125 | end 126 | 127 | def csrf_tag 128 | "" 129 | end 130 | 131 | def attempts_events(acks: nil) 132 | auth = "Bearer #{client_id} #{config.attempts_shared_secret}" 133 | 134 | params = { 135 | maxEvents: 100, 136 | acks:, 137 | } 138 | 139 | connection = Faraday.new( 140 | url: config.attempts_url, 141 | params: params.compact, 142 | headers:{'Authorization' => auth }, 143 | ) 144 | 145 | response = connection.post 146 | if response.status != 200 && ENV['ENABLE_LOGGING'] == 'true' 147 | # rubocop:disable Layout/LineLength 148 | settings.logger.info("got !200 trying to query #{config.attempts_url} ") 149 | # rubocop:enable Layout/LineLength 150 | end 151 | raise AppError.new(response.body) if response.status != 200 152 | 153 | sets = JSON.parse(connection.post.body)['sets'] 154 | 155 | sets.values.map do |jwe| 156 | JSON.parse(JWE.decrypt(jwe, config.sp_private_key)) 157 | end 158 | end 159 | end 160 | # rubocop:enable Metrics/BlockLength 161 | 162 | def config 163 | @config ||= Config.new 164 | end 165 | 166 | get '/' do 167 | login_msg = session.delete(:login_msg) 168 | logout_msg = session.delete(:logout_msg) 169 | user_email = session[:email] 170 | userinfo = session.delete(:userinfo) 171 | 172 | ial = params[:ial] 173 | aal = params[:aal] 174 | 175 | ial = prepare_step_up_flow(session: session, ial: ial, aal: aal) 176 | 177 | erb :index, locals: { 178 | ial: ial, 179 | aal: aal, 180 | login_msg: login_msg, 181 | logout_msg: logout_msg, 182 | user_email: user_email, 183 | logout_uri: logout_uri, 184 | userinfo: userinfo, 185 | access_denied: params[:error] == 'access_denied', 186 | } 187 | rescue AppError => e 188 | [500, erb(:errors, locals: { error: e.message })] 189 | rescue Errno::ECONNREFUSED, Faraday::ConnectionFailed => e 190 | [500, erb(:errors, locals: { error: e.inspect })] 191 | end 192 | 193 | get '/auth/request' do 194 | simulate_csp_issue_if_selected(session: session, simulate_csp: params[:simulate_csp]) 195 | 196 | session[:state] = random_value 197 | session[:nonce] = random_value 198 | session[:code_verifier] = random_value if use_pkce? 199 | 200 | ial = prepare_step_up_flow(session: session, ial: params[:ial], aal: params[:aal]) 201 | auth_url = authorization_url( 202 | state: session[:state], 203 | nonce: session[:nonce], 204 | ial: ial, 205 | aal: params[:aal], 206 | scopes: params[:requested_scopes] || [], 207 | code_verifier: session[:code_verifier], 208 | ) 209 | 210 | settings.logger.info("Redirecting to #{auth_url}") 211 | 212 | redirect to(auth_url) 213 | end 214 | 215 | # rubocop:disable Metrics/BlockLength 216 | get '/auth/result' do 217 | code = params[:code] 218 | error = params[:error] 219 | 220 | redirect to('/?error=access_denied') if error == 'access_denied' 221 | 222 | return render_error(error || 'missing callback param: code') unless code 223 | return render_error('invalid state') if session[:state] != params[:state] 224 | 225 | token_response = token(code) 226 | access_token = token_response[:access_token] 227 | id_token = token_response[:id_token] 228 | jwt = JWT.decode(id_token, idp_public_key, true, algorithm: 'RS256', leeway: 10).first 229 | 230 | return render_error('invalid nonce') if jwt['nonce'] != session[:nonce] 231 | 232 | userinfo_response = userinfo(access_token) 233 | session.delete(:nonce) 234 | session.delete(:state) 235 | 236 | if session.delete(:step_up_enabled) 237 | aal = session.delete(:step_up_aal) 238 | 239 | redirect to("/auth/request?aal=#{aal}&ial=2") 240 | elsif session.delete(:simulate_csp) 241 | redirect to('https://www.example.com/') 242 | else 243 | session[:login_msg] = 'ok' 244 | session[:userinfo] = userinfo_response 245 | session[:email] = session[:userinfo][:email] 246 | 247 | redirect to('/') 248 | end 249 | rescue AppError => e 250 | [500, erb(:errors, locals: { error: e.message })] 251 | end 252 | 253 | get '/failure_to_proof' do 254 | erb :failure_to_proof 255 | end 256 | 257 | post '/handle-logout' do 258 | session.delete(:userinfo) 259 | session.delete(:email) 260 | session.delete(:step_up_enabled) 261 | session.delete(:step_up_aal) 262 | session.delete(:irs) 263 | 264 | redirect to(logout_uri) 265 | end 266 | 267 | get '/logout' do 268 | session[:logout_msg] = 'ok' 269 | redirect to('/') 270 | end 271 | 272 | get '/api/health' do 273 | 274 | content_type :json 275 | { 276 | authorization_endpoint: openid_configuration.fetch('authorization_endpoint'), 277 | private_key_fingerprint: Digest::SHA1.hexdigest(config.sp_private_key.to_der), 278 | healthy: true, 279 | }.to_json 280 | rescue StandardError => e 281 | halt 500, { 282 | error: e.inspect, 283 | healthy: false, 284 | }.to_json 285 | 286 | end 287 | 288 | get '/attempts-api' do 289 | erb :attempts, locals: { 290 | attempts_events: attempts_events, 291 | } 292 | end 293 | 294 | post '/ack-events' do 295 | acks = params[:jtis].split(',') 296 | attempts_events(acks:) 297 | 298 | redirect to('attempts-api') 299 | end 300 | 301 | private 302 | 303 | def render_error(error) 304 | erb :errors, locals: { error: error } 305 | end 306 | 307 | def authorization_url(state:, nonce:, ial:, scopes:, aal:, code_verifier:) 308 | endpoint = openid_configuration[:authorization_endpoint] 309 | request_params = { 310 | client_id: client_id, 311 | response_type: 'code', 312 | acr_values: acr_values(ial: ial, aal: aal), 313 | vtr: vtr_value(ial: ial, aal: aal), 314 | vtm: vtm_value(ial:), 315 | scope: scopes.join(' '), 316 | redirect_uri: File.join(config.redirect_uri, '/auth/result'), 317 | state: state, 318 | nonce: nonce, 319 | prompt: 'select_account', 320 | attempts_api_session_id: SecureRandom.uuid, 321 | } 322 | 323 | if code_verifier 324 | request_params[:code_challenge] = url_safe_code_challenge(code_verifier) 325 | request_params[:code_challenge_method] = 'S256' 326 | end 327 | 328 | "#{endpoint}?#{request_params.compact.to_query}" 329 | end 330 | 331 | def simulate_csp_issue_if_selected(session:, simulate_csp:) 332 | if simulate_csp 333 | session[:simulate_csp] = 'true' 334 | else 335 | session.delete(:simulate_csp) 336 | end 337 | end 338 | 339 | def prepare_step_up_flow(session:, ial:, aal: nil) 340 | if ial == 'step-up' 341 | ial = '1' 342 | session[:step_up_enabled] = 'true' 343 | session[:step_up_aal] = aal if /^\d$/.match?(aal) 344 | else 345 | session.delete(:step_up_enabled) 346 | session.delete(:step_up_aal) 347 | end 348 | 349 | ial 350 | end 351 | 352 | def acr_values(ial:, aal:) 353 | return if requires_enhanced_ipp?(ial) || requires_facial_match_vot?(ial) 354 | 355 | values = [] 356 | 357 | values << (semantic_ial_values_enabled? ? semantic_ial_values[ial] : legacy_ial_values[ial]) 358 | 359 | values << { 360 | '2' => 'http://idmanagement.gov/ns/assurance/aal/2', 361 | '2-phishing_resistant' => 'http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true', 362 | '2-hspd12' => 'http://idmanagement.gov/ns/assurance/aal/2?hspd12=true', 363 | }[aal] 364 | 365 | values.compact.join(' ') 366 | end 367 | 368 | def legacy_ial_values 369 | { 370 | '0' => 'http://idmanagement.gov/ns/assurance/ial/0', 371 | '1' => 'http://idmanagement.gov/ns/assurance/ial/1', 372 | nil => 'http://idmanagement.gov/ns/assurance/ial/1', 373 | '2' => 'http://idmanagement.gov/ns/assurance/ial/2', 374 | 'facial-match-preferred' => 'http://idmanagement.gov/ns/assurance/ial/2?bio=preferred', 375 | 'facial-match-required' => 'http://idmanagement.gov/ns/assurance/ial/2?bio=required', 376 | } 377 | end 378 | 379 | def semantic_ial_values 380 | { 381 | '0' => 'http://idmanagement.gov/ns/assurance/ial/0', 382 | '1' => 'urn:acr.login.gov:auth-only', 383 | nil => 'urn:acr.login.gov:auth-only', 384 | '2' => 'urn:acr.login.gov:verified', 385 | 'facial-match-required' => 'urn:acr.login.gov:verified-facial-match-required', 386 | 'facial-match-preferred' => 'urn:acr.login.gov:verified-facial-match-preferred', 387 | } 388 | end 389 | 390 | def vtr_value(ial:, aal:) 391 | return if does_not_require_enhanced_ipp?(ial) && does_not_require_facial_match_vot?(ial) 392 | 393 | values = ['C1'] 394 | 395 | values << { 396 | '2' => 'C2', 397 | '2-phishing_resistant' => 'C2.Ca', 398 | '2-hspd12' => 'C2.Cb', 399 | }[aal] 400 | 401 | values << { 402 | '2' => 'P1', 403 | 'facial-match-vot' => 'P1.Pb', 404 | 'enhanced-ipp-required' => 'P1.Pe', 405 | }[ial] 406 | 407 | vtr_list = [values.compact.join('.')] 408 | 409 | if ial == '0' 410 | proofing_vector = values.dup + ['P1'] 411 | vtr_list = [proofing_vector.compact.join('.'), *vtr_list] 412 | end 413 | 414 | vtr_list.to_json 415 | end 416 | 417 | def semantic_ial_values_enabled? 418 | ENV['semantic_ial_values_enabled'] == 'true' 419 | end 420 | 421 | def use_pkce? 422 | ENV['PKCE'] == 'true' 423 | end 424 | 425 | def vtm_value(ial) 426 | return if does_not_require_enhanced_ipp?(ial) 427 | 'https://developer.login.gov/vot-trust-framework' 428 | end 429 | 430 | def requires_facial_match_vot?(ial) 431 | return false if config.vtr_disabled? 432 | 433 | ial == 'facial-match-vot' 434 | end 435 | 436 | def does_not_require_facial_match_vot?(ial) 437 | !requires_facial_match_vot?(ial) 438 | end 439 | 440 | def requires_enhanced_ipp?(ial) 441 | return false unless config.eipp_allowed? 442 | 443 | ial == 'enhanced-ipp-required' 444 | end 445 | 446 | def does_not_require_enhanced_ipp?(ial) 447 | !requires_enhanced_ipp?(ial) 448 | end 449 | 450 | def openid_configuration 451 | if config.cache_oidc_config? 452 | OpenidConfiguration.cached 453 | else 454 | OpenidConfiguration.live 455 | end 456 | end 457 | 458 | def idp_public_key 459 | if config.cache_oidc_config? 460 | OpenidConfiguration.cached_idp_public_key(openid_configuration) 461 | else 462 | OpenidConfiguration.live_idp_public_key(openid_configuration) 463 | end 464 | end 465 | 466 | def token(code) 467 | token_params = { 468 | grant_type: 'authorization_code', 469 | code: code, 470 | } 471 | 472 | if use_pkce? 473 | token_params[:code_verifier] = session[:code_verifier] 474 | else 475 | token_params[:client_assertion_type] = JWT_CLIENT_ASSERTION_TYPE 476 | token_params[:client_assertion] = client_assertion_jwt 477 | end 478 | 479 | response = Faraday.post( 480 | openid_configuration[:token_endpoint], 481 | token_params, 482 | ) 483 | if response.status != 200 && ENV['ENABLE_LOGGING'] == 'true' 484 | # rubocop:disable Layout/LineLength 485 | settings.logger.info("got !200 trying to query #{openid_configuration[:token_endpoint]} with #{token_params}") 486 | # rubocop:enable Layout/LineLength 487 | end 488 | raise AppError.new(response.body) if response.status != 200 489 | json response.body 490 | end 491 | 492 | def client_assertion_jwt 493 | jwt_payload = { 494 | iss: client_id, 495 | sub: client_id, 496 | aud: openid_configuration[:token_endpoint], 497 | jti: random_value, 498 | exp: Time.now.to_i + 1000, 499 | } 500 | 501 | JWT.encode(jwt_payload, config.sp_private_key, 'RS256') 502 | end 503 | 504 | def userinfo(access_token) 505 | url = openid_configuration[:userinfo_endpoint] 506 | 507 | connection = Faraday.new(url: url, headers:{'Authorization' => "Bearer #{access_token}" }) 508 | JSON.parse(connection.get('').body).with_indifferent_access 509 | end 510 | 511 | def client_id 512 | return config.mock_irs_client_id if session[:irs] 513 | 514 | use_pkce? ? config.client_id_pkce : config.client_id 515 | end 516 | 517 | def logout_uri 518 | endpoint = openid_configuration[:end_session_endpoint] 519 | request_params = { 520 | client_id: client_id, 521 | post_logout_redirect_uri: File.join(config.redirect_uri, 'logout'), 522 | state: SecureRandom.hex, 523 | }.to_query 524 | 525 | "#{endpoint}?#{request_params}" 526 | end 527 | 528 | def json(response) 529 | JSON.parse(response.to_s).with_indifferent_access 530 | end 531 | 532 | def random_value 533 | SecureRandom.hex 534 | end 535 | 536 | def url_safe_code_challenge(code_verifier) 537 | Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)) 538 | end 539 | 540 | def maybe_redact_ssn(ssn) 541 | if config.redact_ssn? 542 | # redact all characters since they're all sensitive 543 | ssn = ssn&.gsub(/\d/, '#') 544 | end 545 | 546 | ssn 547 | end 548 | end 549 | end 550 | -------------------------------------------------------------------------------- /config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'aws-sdk-secretsmanager' 5 | require 'yaml' 6 | 7 | module LoginGov 8 | module OidcSinatra 9 | # Class holding configuration for this sample app. Defaults come from 10 | # .env via `#default_config` 11 | class Config 12 | def initialize() 13 | @config = default_config 14 | end 15 | 16 | def attempts_shared_secret 17 | @config.fetch('attempts_shared_secret') 18 | end 19 | 20 | def attempts_url 21 | "#{idp_url}/api/attempts/poll" 22 | end 23 | 24 | def idp_url 25 | @config.fetch('idp_url') 26 | end 27 | 28 | def acr_values 29 | @config.fetch('acr_values') 30 | end 31 | 32 | def redirect_uri 33 | @config.fetch('redirect_uri') 34 | end 35 | 36 | def client_id 37 | @config.fetch('client_id') 38 | end 39 | 40 | def client_id_pkce 41 | @config.fetch('client_id_pkce') 42 | end 43 | 44 | def mock_irs_client_id 45 | @config.fetch('mock_irs_client_id') 46 | end 47 | 48 | def redact_ssn? 49 | @config.fetch('redact_ssn') 50 | end 51 | 52 | def cache_oidc_config? 53 | @config.fetch('cache_oidc_config') 54 | end 55 | 56 | def vtr_disabled? 57 | @config.fetch('vtr_disabled') 58 | end 59 | 60 | def vtr_enabled? 61 | !vtr_disabled? 62 | end 63 | 64 | def eipp_allowed? 65 | @config.fetch('eipp_allowed') 66 | end 67 | 68 | # @return [OpenSSL::PKey::RSA] 69 | def sp_private_key 70 | return @sp_private_key if @sp_private_key 71 | 72 | key = ENV['sp_private_key'] || get_sp_private_key_raw(@config.fetch('sp_private_key_path')) 73 | @sp_private_key = OpenSSL::PKey::RSA.new(key) 74 | end 75 | 76 | # Define the default configuration values. 77 | # 78 | # @return [Hash] 79 | # 80 | def default_config 81 | data = { 82 | 'acr_values' => ENV['acr_values'] || 'http://idmanagement.gov/ns/assurance/ial/1', 83 | 'client_id' => ENV['client_id'] || 'urn:gov:gsa:openidconnect:sp:sinatra', 84 | 'client_id_pkce' => ENV['client_id_pkce'] || 'urn:gov:gsa:openidconnect:sp:sinatra_pkce', 85 | 'mock_irs_client_id' => ENV['mock_irs_client_id'] || 86 | 'urn:gov:gsa:openidconnect:sp:mock_irs', 87 | 'redirect_uri' => ENV['redirect_uri'] || 'http://localhost:9292/', 88 | 'sp_private_key_path' => ENV['sp_private_key_path'] || './config/demo_sp.key', 89 | 'redact_ssn' => true, 90 | 'cache_oidc_config' => true, 91 | 'vtr_disabled' => ENV.fetch('vtr_disabled', 'false') == 'true', 92 | 'eipp_allowed' => ENV.fetch('eipp_allowed', 'false') == 'true', 93 | 'attempts_shared_secret' => ENV['attempts_shared_secret'], 94 | } 95 | 96 | # EC2 deployment defaults 97 | 98 | env = ENV['idp_environment'] || 'int' 99 | domain = ENV['idp_domain'] || 'identitysandbox.gov' 100 | 101 | data['idp_url'] = ENV.fetch('idp_url', nil) 102 | unless data['idp_url'] 103 | if env == 'prod' 104 | data['idp_url'] = "https://secure.#{domain}" 105 | else 106 | data['idp_url'] = "https://idp.#{env}.#{domain}" 107 | end 108 | end 109 | data['sp_private_key'] = ENV.fetch('sp_private_key', nil) 110 | 111 | data 112 | end 113 | 114 | private 115 | 116 | def get_sp_private_key_raw(path) 117 | if path.start_with?('aws-secretsmanager:') 118 | secret_id = path.split(':', 2).fetch(1) 119 | opts = {} 120 | smc = Aws::SecretsManager::Client.new(opts) 121 | begin 122 | return smc.get_secret_value(secret_id: secret_id).secret_string 123 | rescue Aws::SecretsManager::Errors::ResourceNotFoundException 124 | if ENV['deployed'] 125 | raise 126 | end 127 | end 128 | 129 | warn "#{secret_id.inspect}: not found in AWS Secrets Manager, using demo key" 130 | get_sp_private_key_raw(demo_private_key_path) 131 | else 132 | File.read(path) 133 | end 134 | end 135 | 136 | def demo_private_key_path 137 | "#{File.dirname(__FILE__)}/config/demo_sp.key" 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './app' 4 | run LoginGov::OidcSinatra::OpenidConnectRelyingParty.new 5 | -------------------------------------------------------------------------------- /config/demo_sp.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDgDCCAmgCCQCwpieA9CKuDDANBgkqhkiG9w0BAQUFADCBgTEYMBYGA1UEAwwP 3 | U1AgU2luYXRyYSBEZW1vMQwwCgYDVQQKDANHU0ExDDAKBgNVBAsMAzE4ZjETMBEG 4 | A1UEBwwKV2FzaGluZ3RvbjELMAkGA1UECAwCREMxCzAJBgNVBAYTAlVTMRowGAYJ 5 | KoZIhvcNAQkBFgsxOGZAZ3NhLmdvdjAeFw0xNjA4MTgyMDI5MTFaFw0yNjA4MTYy 6 | MDI5MTFaMIGBMRgwFgYDVQQDDA9TUCBTaW5hdHJhIERlbW8xDDAKBgNVBAoMA0dT 7 | QTEMMAoGA1UECwwDMThmMRMwEQYDVQQHDApXYXNoaW5ndG9uMQswCQYDVQQIDAJE 8 | QzELMAkGA1UEBhMCVVMxGjAYBgkqhkiG9w0BCQEWCzE4ZkBnc2EuZ292MIIBIjAN 9 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxh42KlIlEGtl/6NOfN+5tSg/eggU 10 | sW0dYdWIhshawe7h/9EhJemABXGI+VIiYHOt9QPE3HZ9ky2rUv7iC0MslIvI2sdQ 11 | 0aPPaYYCXbh8iU2kYEcRPIU9g5NNlKHyUS6jp5HDWeRmN2lF03/dBcnRl9VWPOmH 12 | C9iS44xihTimcTZjlMfd59WXSNSbUACGR7vNjAA4N/DnqEUCWd9h016QgtRll6Qj 13 | jSVBGmaHZjpOWsP+2I7evx51rUinGsgHuihxFT5dL/EcJ3RsncKYku47ekyAMMsm 14 | 2sJecLgUk4YPvdQzafK/7jeyCtBFcyRTvBs7yapgcuW+LW8PUfu7NT+vTQIDAQAB 15 | MA0GCSqGSIb3DQEBBQUAA4IBAQBO3eTacRFKnFjQ9OCLXzJx0nt64jlQeiiElePe 16 | i3l6t8YYYf81Lt2PG8kqs2/NJ1enKSFIH9bHM2chv82zKXgLUumCsLLo45MbxLEQ 17 | fZfzbRYDxKWcOuf4yLrjL4bp7Bv0onnG9hSCLT9yTFskwEP6W9XW44W50RhrOfYN 18 | Bfrscg6b8uq15y5WrH+A9wzDlZfvH7ouNAnWrp0GXI6LN8vVPYX0vBfZuiOqI32H 19 | IYOalU+bIBpQt6EGN/mWBu7yZtgxKULZamJUUpd5xpcPcGKwf59etPVMTSxgeeQY 20 | MFjibtIlMmAweHgIqDyF2s8Etz8hlcKrXIUAK5CoMvgUn41V 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /config/demo_sp.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAxh42KlIlEGtl/6NOfN+5tSg/eggUsW0dYdWIhshawe7h/9Eh 3 | JemABXGI+VIiYHOt9QPE3HZ9ky2rUv7iC0MslIvI2sdQ0aPPaYYCXbh8iU2kYEcR 4 | PIU9g5NNlKHyUS6jp5HDWeRmN2lF03/dBcnRl9VWPOmHC9iS44xihTimcTZjlMfd 5 | 59WXSNSbUACGR7vNjAA4N/DnqEUCWd9h016QgtRll6QjjSVBGmaHZjpOWsP+2I7e 6 | vx51rUinGsgHuihxFT5dL/EcJ3RsncKYku47ekyAMMsm2sJecLgUk4YPvdQzafK/ 7 | 7jeyCtBFcyRTvBs7yapgcuW+LW8PUfu7NT+vTQIDAQABAoIBAATd9SKxBewTV1wi 8 | XOlAbcV/iZ+r1yZMse4XWtVE0sX04mRwdMoDpHt9wImvdNy9usZMI2fvGUKmWpEd 9 | Zdw0+EFl9bc6MkgUKCJFgoVe5OBSofkjhc7gzxQOaFeAav2HkcmqEQyCSeiOk4KQ 10 | n5Nm09lRCl9QtVqbJXCloD35mE0rp9HmKnkVaVE2sWpfCSIwd3MpJm8BCd0MMmMZ 11 | AtZuusDb2WHNlnE5r0MKs9itnNjCqs/A11j/lEXGPDECVDmHSCMBdg+8k6nDkyF6 12 | evB1udocBjO9Va6M2NgiJaOsw2p8DV8SGuZRwq9/xLTNmOxKR8GQT9XdPs+gCzN8 13 | 4Ty3QdkCgYEA4lV1+gW/ZzmT4Dq7+ZYFNuCoUA2tbzWyB5MxseABTuabjIx5LPV3 14 | uagT7VXH3w7HPDfNii+wkplcOHYVFg5HEQdyH3Yhg/86vy7CN8zXwFQTPHCGbAaU 15 | fwT5WiehAkhHoiY7hmJ6ljFy9AcTKuHpoGPaQtjDpJyZb5gWUafWs3cCgYEA4BX7 16 | A3xXUQiSrY6tQSFmMFQc0jKdpejdfABVm6+yS4i6PZdB0jW1uE4TLRWfCDOT4lzB 17 | eSg+7+avYRbIX2iKSwT1GFPyCP9SqH72xW8qJfK5sS9UFh3fvOttq9I7D3yhQtra 18 | 2M12WalFwKK3hgw/l5s6bWUr2dMpxA3WqyCSPFsCgYAtZKZ8tppkwY9+8UiyDfyN 19 | vREEvTmDjGlgS40z95FLmcSos6O5+KFCgws8FnA3tGcRFMAMbvQi8s8kI8qm2cY3 20 | DB8/YBnot0+4+E/LkTHUSQhynr7W+5rcvsqj4j7Qjl6PjstxcF7VsKU1fBXEC/sn 21 | R9+GeKMEaMP9NauERP0ykQKBgQDMV97+D75EO/Ad6rTdZsqcGafqmpOePtrygA+R 22 | GEgbj0Rec/dm7OmYd0IPY98RCI+75V/czzGNbSzS+YahUmCCBrRGig512/cRhi4c 23 | XsHttwlUpVclj/p+eaYaAG0xMzKPF6pn9/0LyEu+XePDjpnS84/1QEQbr+8vRHtU 24 | tDAGbwKBgEBkTWYV81phzRv45/vYahfCtgNieingdIL1WaHiWUeHmRKU61PJRL/w 25 | OQfVU78FtvVhUkSFp2+od4xzaIked0WgP/ukFo2X8kSkYfDdjpciwXyJyCMphxqn 26 | 5vb5v07gWfHSAoVOsXxVJmsB8sbDCSl02SmDkzotZW8wtPhMcAWN 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /config/newrelic.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This file configures the New Relic Agent. New Relic monitors Ruby, Java, 3 | # .NET, PHP, Python, Node, and Go applications with deep visibility and low 4 | # overhead. For more information, visit www.newrelic.com. 5 | 6 | # Generated October 28, 2022, for version 8.8.0 7 | # 8 | # For full documentation of agent configuration options, please refer to 9 | # https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration 10 | 11 | common: &default_settings 12 | # Required license key associated with your New Relic account. 13 | # Or, pass it in with $NEW_RELIC_LICENSE_KEY 14 | # license_key: 15 | 16 | # Your application name. Renaming here affects where data displays in New 17 | # Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications 18 | # Or, pass it in with $NEW_RELIC_APP_NAME 19 | # app_name: 20 | 21 | # To disable the agent regardless of other settings, uncomment the following: 22 | # agent_enabled: false 23 | 24 | # Logging level for log/newrelic_agent.log; options are error, warn, info, or 25 | # debug. 26 | log_level: info 27 | 28 | # All of the following configuration options are optional. Review them, and 29 | # uncomment or edit them if they appear relevant to your application needs. 30 | 31 | # If `true`, all logging-related features for the agent can be enabled or disabled 32 | # independently. If `false`, all logging-related features are disabled. 33 | # application_logging.enabled: true 34 | 35 | # If `true`, the agent captures log records emitted by this application. 36 | application_logging: 37 | forwarding: 38 | enabled: false 39 | 40 | # Defines the maximum number of log records to buffer in memory at a time. 41 | # application_logging.forwarding.max_samples_stored: 10000 42 | 43 | # If `true`, the agent captures metrics related to logging for this application. 44 | # application_logging.metrics.enabled: true 45 | 46 | # If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans. 47 | # application_logging.local_decorating.enabled: false 48 | 49 | # If `true`, the agent will report source code level metrics for traced methods 50 | # code_level_metrics.enabled: false 51 | 52 | # If true, enables transaction event sampling. 53 | # transaction_events.enabled: true 54 | 55 | # Defines the maximum number of request events reported from a single harvest. 56 | # transaction_events.max_samples_stored: 1200 57 | 58 | # Prefix of attributes to exclude from all destinations. Allows * as wildcard at 59 | # end. 60 | # attributes_exclude: [] 61 | 62 | # Prefix of attributes to include in all destinations. Allows * as wildcard at 63 | # end. 64 | # attributes_include: [] 65 | 66 | # If true, enables capture of attributes for all destinations. 67 | # attributes.enabled: true 68 | 69 | # If true, enables an audit log which logs communications with the New Relic 70 | # collector. 71 | # audit_log.enabled: false 72 | 73 | # List of allowed endpoints to include in audit log. 74 | # audit_log.endpoints: [".*"] 75 | 76 | # Specifies a path to the audit log file (including the filename). 77 | # audit_log.path: "/audit_log" 78 | 79 | # Specify a list of constants that should prevent the agent from starting 80 | # automatically. Separate individual constants with a comma ,. 81 | # For example, Rails::Console,UninstrumentedBackgroundJob. 82 | # autostart.denylisted_constants: "rails::console" 83 | 84 | # Defines a comma-delimited list of executables that the agent should not 85 | # instrument. For example, rake,my_ruby_script.rb. 86 | # autostart.denylisted_executables: "irb,rspec" 87 | 88 | # Defines a comma-delimited list of Rake tasks that the agent should not 89 | # instrument. For example, assets:precompile,db:migrate. 90 | # autostart.denylisted_rake_tasks: "about,assets:clean,assets:clobber,assets:environment,assets:precompile,assets:precompile:all,db:create,db:drop,db:fixtures:load,db:migrate,db:migrate:status,db:rollback,db:schema:cache:clear,db:schema:cache:dump,db:schema:dump,db:schema:load,db:seed,db:setup,db:structure:dump,db:version,doc:app,log:clear,middleware,notes,notes:custom,rails:template,rails:update,routes,secret,spec,spec:features,spec:requests,spec:controllers,spec:helpers,spec:models,spec:views,spec:routing,spec:rcov,stats,test,test:all,test:all:db,test:recent,test:single,test:uncommitted,time:zones:all,tmp:clear,tmp:create,webpacker:compile" 91 | 92 | # Backports the faster Active Record connection lookup introduced in Rails 6, 93 | # which improves agent performance when instrumenting Active Record. Note that 94 | # this setting may not be compatible with other gems that patch Active Record. 95 | # backport_fast_active_record_connection_lookup: false 96 | 97 | # If true, the agent captures attributes from browser monitoring. 98 | # browser_monitoring_attributes.enabled: false 99 | 100 | # Prefix of attributes to exclude from browser monitoring. Allows * as wildcard 101 | # at end. 102 | # browser_monitoring.attributes.exclude: [] 103 | 104 | # Prefix of attributes to include in browser monitoring. Allows * as wildcard at 105 | # end. 106 | # browser_monitoring.attributes.include: [] 107 | 108 | # This is true by default, this enables auto-injection of the JavaScript header 109 | # for page load timing (sometimes referred to as real user monitoring or RUM). 110 | # browser_monitoring.auto_instrument: true 111 | 112 | # Manual override for the path to your local CA bundle. This CA bundle will be 113 | # used to validate the SSL certificate presented by New Relic's data collection 114 | # service. 115 | # ca_bundle_path: nil 116 | 117 | # Enable or disable the capture of memcache keys from transaction traces. 118 | # capture_memcache_keys: false 119 | 120 | # When true, the agent captures HTTP request parameters and attaches them to 121 | # transaction traces, traced errors, and TransactionError events. When using the 122 | # capture_params setting, the Ruby agent will not attempt to filter secret 123 | # information. Recommendation: To filter secret information from request 124 | # parameters,use the attributes.include setting instead. For more information, 125 | # see the Ruby attribute examples. 126 | # capture_params: false 127 | 128 | # If true, the agent will clear Tracer::State in Agent.drop_buffered_data. 129 | # clear_transaction_state_after_fork: false 130 | 131 | # Path to newrelic.yml. If undefined, the agent checks the following directories 132 | # (in order): config/newrelic.yml, newrelic.yml, $HOME/.newrelic/newrelic.yml 133 | # and $HOME/newrelic.yml. 134 | # config_path: newrelic.yml 135 | 136 | # If true, enables cross application tracing. Cross application tracing is now 137 | # deprecated, and disabled by default. Distributed tracing is replacing cross 138 | # application tracing as the default means of tracing between services. 139 | # To continue using it, set `cross_application_tracer.enabled: true` and 140 | # `distributed_tracing.enabled: false` 141 | # cross_application_tracer.enabled: false 142 | 143 | # If false, custom attributes will not be sent on New Relic Insights events. 144 | # custom_attributes.enabled: true 145 | 146 | # If true, the agent captures New Relic Insights custom events. 147 | # custom_insights_events.enabled: true 148 | 149 | # Specify a maximum number of custom Insights events to buffer in memory at a 150 | # time. 151 | # custom_insights_events.max_samples_stored: 1000 152 | 153 | # If false, the agent will not add database_name parameter to transaction or # 154 | # slow sql traces. 155 | # datastore_tracer.database_name_reporting.enabled: true 156 | 157 | # If false, the agent will not report datastore instance metrics, nor add host 158 | # or port_path_or_id parameters to transaction or slow SQL traces. 159 | # datastore_tracer.instance_reporting.enabled: true 160 | 161 | # If true, disables Action Cable instrumentation. 162 | # disable_action_cable_instrumentation: false 163 | 164 | # If true, disables instrumentation for Active Record 4, 5, and 6. 165 | # disable_active_record_notifications: false 166 | 167 | # If true, disables Active Storage instrumentation. 168 | # disable_active_storage: false 169 | 170 | # If true, disables Active Job instrumentation. 171 | # disable_activejob: false 172 | 173 | # If true, disables Active Record instrumentation. 174 | # disable_active_record_instrumentation: false 175 | 176 | # If true, the agent won't sample the CPU usage of the host process. 177 | # disable_cpu_sampler: false 178 | 179 | # If true, disables DataMapper instrumentation. 180 | # disable_data_mapper: false 181 | 182 | # If true, the agent won't measure the depth of Delayed Job queues. 183 | # disable_delayed_job_sampler: false 184 | 185 | # If true, disables the use of GC::Profiler to measure time spent in garbage 186 | # collection 187 | # disable_gc_profiler: false 188 | 189 | # If true, the agent won't sample the memory usage of the host process. 190 | # disable_memory_sampler: false 191 | 192 | # If true, the agent won't wrap third-party middlewares in instrumentation 193 | # (regardless of whether they are installed via Rack::Builder or Rails). 194 | # disable_middleware_instrumentation: false 195 | 196 | # If true, disables the collection of sampler metrics. Sampler metrics are 197 | # metrics that are not event-based (such as CPU time or memory usage). 198 | # disable_samplers: false 199 | 200 | # If true, disables Sequel instrumentation. 201 | # disable_sequel_instrumentation: false 202 | 203 | # If true, disables Sidekiq instrumentation. 204 | # disable_sidekiq: false 205 | 206 | # If true, disables agent middleware for Sinatra. This middleware is responsible 207 | # for advanced feature support such as distributed tracing, page load 208 | # timing, and error collection. 209 | # disable_sinatra_auto_middleware: false 210 | 211 | # If true, disables view instrumentation. 212 | # disable_view_instrumentation: false 213 | 214 | # If true, the agent won't sample performance measurements from the Ruby VM. 215 | # disable_vm_sampler: false 216 | 217 | # Distributed tracing tracks and observes service requests as they flow through distributed systems. 218 | # With distributed tracing data, you can quickly pinpoint failures or performance issues and fix them. 219 | distributed_tracing: 220 | enabled: false 221 | 222 | # If true, the agent captures attributes from error collection. 223 | # error_collector.attributes.enabled: false 224 | 225 | # Prefix of attributes to exclude from error collection. 226 | # Allows * as wildcard at end. 227 | # error_collector.attributes.exclude: [] 228 | 229 | # Prefix of attributes to include in error collection. 230 | # Allows * as wildcard at end. 231 | # error_collector.attributes.include: [] 232 | 233 | # If true, the agent collects TransactionError events. 234 | # error_collector.capture_events: true 235 | 236 | # If true, the agent captures traced errors and error count metrics. 237 | error_collector.enabled: true 238 | 239 | # A list of error classes that the agent should treat as expected. 240 | # error_collector.expected_classes: [] 241 | 242 | # A map of error classes to a list of messages. When an error of one of the 243 | # classes specified here occurs, if its error message contains one of the 244 | # strings corresponding to it here, that error will be treated as expected. 245 | # error_collector.expected_messages: {} 246 | 247 | # A comma separated list of status codes, possibly including ranges. Errors 248 | # associated with these status codes, where applicable, will be treated as 249 | # expected. 250 | # error_collector.expected_status_codes: "" 251 | 252 | # A list of error classes that the agent should ignore. 253 | # error_collector.ignore_classes: [] 254 | 255 | # A map of error classes to a list of messages. When an error of one of the 256 | # classes specified here occurs, if its error message contains one of the 257 | # strings corresponding to it here, that error will be ignored. 258 | # error_collector.ignore_messages: "" 259 | 260 | # A comma separated list of status codes, possibly including ranges. Errors 261 | # associated with these status codes, where applicable, will be ignored. 262 | # error_collector.ignore_status_codes: "" 263 | 264 | # Defines the maximum number of frames in an error backtrace. Backtraces over 265 | # this amount are truncated at the beginning and end. 266 | # error_collector.max_backtrace_frames: 50 267 | 268 | # Defines the maximum number of TransactionError events sent to Insights per 269 | # harvest cycle. 270 | # error_collector.max_event_samples_stored: 100 271 | 272 | # Allows newrelic distributed tracing headers to be suppressed on outbound 273 | # requests. 274 | # exclude_newrelic_header: false 275 | 276 | # Forces the exit handler that sends all cached data to collector before 277 | # shutting down to be installed regardless of detecting scenarios where it 278 | # generally should bot be. Known use-case for this option is where Sinatra is 279 | # running as an embedded service within another framework and the agent is 280 | # detecting the Sinatra app and skipping the at_exit handler as a result. 281 | # Sinatra classically runs the entire application in an at_exit block and would 282 | # otherwise misbehave if the Agent's at_exit handler was also installed in 283 | # those circumstances. Note: send_data_on_exit should also be set to true in 284 | # tandem with this setting. 285 | # force_install_exit_handler: false 286 | 287 | # Ordinarily the agent reports dyno names with a trailing dot and process ID 288 | # (for example, worker.3). You can remove this trailing data by specifying the 289 | # prefixes you want to report without trailing data (for example, worker). 290 | # heroku.dyno_name_prefixes_to_shorten: ["scheduler", "run"] 291 | 292 | # If true, the agent uses Heroku dyno names as the hostname. 293 | # heroku.use_dyno_names: true 294 | 295 | # If true, enables high security mode. Ensure that you understand the 296 | # implication of enabling high security mode before enabling this setting. 297 | # https://docs.newrelic.com/docs/agents/manage-apm-agents/configuration/high-security-mode/ 298 | # high_security: false 299 | 300 | # Configures the hostname for the Trace Observer Host. When configured, enables 301 | # tail-based sampling by sending all recorded spans to a Trace Observer for 302 | # further sampling decisions, irrespective of any usual agent sampling decision. 303 | # infinite_tracing.trace_observer.host: "" 304 | 305 | # Configures the TCP/IP port for the Trace Observer Host 306 | # infinite_tracing.trace_observer.port: 443 307 | 308 | # Controls auto-instrumentation of bunny at start up. 309 | # May be one of [auto|prepend|chain|disabled]. 310 | # instrumentation.bunny: auto 311 | 312 | # Controls auto-instrumentation of Curb at start up. 313 | # May be one of [auto|prepend|chain|disabled]. 314 | # instrumentation.curb: auto 315 | 316 | # Controls auto-instrumentation of Delayed Job at start up. 317 | # May be one of [auto|prepend|chain|disabled]. 318 | # instrumentation.delayed_job: auto 319 | 320 | # Controls auto-instrumentation of Excon at start up. 321 | # May be one of [enabled|disabled]. 322 | # instrumentation.excon: auto 323 | 324 | # Controls auto-instrumentation of Grape at start up. 325 | # May be one of [auto|prepend|chain|disabled]. 326 | # instrumentation.grape: auto 327 | 328 | # Controls auto-instrumentation of HTTPClient at start up. 329 | # May be one of [auto|prepend|chain|disabled]. 330 | # instrumentation.httpclient: auto 331 | 332 | # Controls auto-instrumentation of http.rb gem at start up. 333 | # May be one of [auto|prepend|chain|disabled]. 334 | # instrumentation.httprb: auto 335 | 336 | # Controls auto-instrumentation of the Ruby standard library Logger.rb. 337 | # May be one of [auto|prepend|chain|disabled]. 338 | # instrumentation.logger: auto 339 | 340 | # Controls auto-instrumentation of ActiveSupport::Logger at start up. 341 | # May be one of [auto|prepend|chain|disabled]. 342 | # instrumentation.active_support.logger: auto 343 | 344 | # Controls auto-instrumentation of memcache-client gem for Memcache at start up. 345 | # May be one of [auto|prepend|chain|disabled]. 346 | # instrumentation.memcache_client: auto 347 | 348 | # Controls auto-instrumentation of dalli gem for Memcache at start up. 349 | # May be one of [auto|prepend|chain|disabled]. 350 | # instrumentation.memcache: auto 351 | 352 | # Controls auto-instrumentation of memcached gem for Memcache at start up. 353 | # May be one of [auto|prepend|chain|disabled]. 354 | # instrumentation.memcached: auto 355 | 356 | # Controls auto-instrumentation of Mongo at start up. 357 | # May be one of [enabled|disabled]. 358 | # instrumentation.mongo: auto 359 | 360 | # Controls auto-instrumentation of Net::HTTP at start up. 361 | # May be one of [auto|prepend|chain|disabled]. 362 | # instrumentation.net_http: auto 363 | 364 | # Controls auto-instrumentation of Puma::Rack::URLMap at start up. 365 | # May be one of [auto|prepend|chain|disabled]. 366 | # instrumentation.puma_rack_urlmap: auto 367 | 368 | # Controls auto-instrumentation of Puma::Rack. When enabled, the agent hooks 369 | # into the to_app method in Puma::Rack::Builder to find gems to instrument 370 | # during application startup. May be one of [auto|prepend|chain|disabled]. 371 | # instrumentation.puma_rack: auto 372 | 373 | # Controls auto-instrumentation of Rack::URLMap at start up. 374 | # May be one of [auto|prepend|chain|disabled]. 375 | # instrumentation.rack_urlmap: auto 376 | 377 | # Controls auto-instrumentation of Rack. When enabled, the agent hooks into the 378 | # to_app method in Rack::Builder to find gems to instrument during application 379 | # startup. May be one of [auto|prepend|chain|disabled]. 380 | # instrumentation.rack: auto 381 | 382 | # Controls auto-instrumentation of rake at start up. 383 | # May be one of [auto|prepend|chain|disabled]. 384 | # instrumentation.rake: auto 385 | 386 | # Controls auto-instrumentation of Redis at start up. 387 | # May be one of [auto|prepend|chain|disabled]. 388 | # instrumentation.redis: auto 389 | 390 | # Controls auto-instrumentation of resque at start up. 391 | # May be one of [auto|prepend|chain|disabled]. 392 | # instrumentation.resque: auto 393 | 394 | # Controls auto-instrumentation of Sinatra at start up. 395 | # May be one of [auto|prepend|chain|disabled]. 396 | instrumentation.sinatra: auto 397 | 398 | # Controls auto-instrumentation of Tilt at start up. 399 | # May be one of [auto|prepend|chain|disabled]. 400 | # instrumentation.tilt: auto 401 | 402 | # Controls auto-instrumentation of Typhoeus at start up. 403 | # May be one of [auto|prepend|chain|disabled]. 404 | # instrumentation.typhoeus: auto 405 | 406 | # Controls auto-instrumentation of the Thread class at start up to allow the agent to correctly nest spans inside of an asyncronous transaction. 407 | # May be one of [auto|prepend|chain|disabled]. 408 | # instrumentation.thread: auto 409 | 410 | # Controls auto-instrumentation of the Thread class at start up to automatically add tracing to all Threads created in the application. 411 | # instrumentation.thread.tracing: false 412 | 413 | # A dictionary of label names and values that will be applied to the data sent 414 | # from this agent. May also be expressed asa semicolon-delimited ; string of 415 | # colon-separated : pairs. 416 | # For example,Server:One;Data Center:Primary. 417 | # labels: "" 418 | 419 | # Defines a name for the log file. 420 | # log_file_name: "newrelic_agent.log" 421 | 422 | # Defines a path to the agent log file, excluding the filename. 423 | # log_file_path: "log/" 424 | 425 | # Specifies a marshaller for transmitting data to the New Relic collector. 426 | # Currently json is the only valid value for this setting. 427 | # marshaller: json 428 | 429 | # If true, the agent will collect metadata about messages and attach them as 430 | # segment parameters. 431 | # message_tracer.segment_parameters.enabled: true 432 | 433 | # If true, the agent captures Mongo queries in transaction traces. 434 | # mongo.capture_queries: true 435 | 436 | # If true, the agent obfuscates Mongo queries in transaction traces. 437 | # mongo.obfuscate_queries: true 438 | 439 | # When true, the agent transmits data about your app to the New Relic collector. 440 | monitor_mode: true 441 | 442 | # If true, uses Module#prepend rather than alias_method for Active Record 443 | # instrumentation. 444 | # prepend_active_record_instrumentation: false 445 | 446 | # Specify a custom host name for display in the New Relic UI 447 | # Be be aware that you cannot rename a hostname, so please rename 448 | # process_host.display_name: "default hostname" 449 | 450 | # Defines a host for communicating with the New Relic collector via a proxy 451 | # server. 452 | # proxy_host: nil 453 | 454 | # Defines a password for communicating with the New Relic collector via a proxy 455 | # server. 456 | # proxy_pass: nil 457 | 458 | # Defines a port for communicating with the New Relic collector via a proxy 459 | # server. 460 | # proxy_port: nil 461 | 462 | # Defines a user for communicating with the New Relic collector via a proxy 463 | # server. 464 | # proxy_user: nil 465 | 466 | # Timeout for waiting on connect to complete before a rake task 467 | # rake.connect_timeout: 10 468 | 469 | # Specify an array of Rake tasks to automatically instrument. 470 | # This configuration option converts the Array to a RegEx list. 471 | # If you'd like to allow all tasks by default, use `rake.tasks: [.+]`. 472 | # Rake tasks will not be instrumented unless they're added to this list. 473 | # For more information, visit the (New Relic Rake Instrumentation docs)[/docs/apm/agents/ruby-agent/background-jobs/rake-instrumentation]. 474 | # rake.tasks: [] 475 | 476 | # Define transactions you want the agent to ignore, by specifying a list of 477 | # patterns matching the URI you want to ignore. 478 | # rules.ignore_url_regexes: [] 479 | 480 | # Applies Language Agent Security Policy settings. 481 | # security_policies_token: "" 482 | 483 | # If true, enables the exit handler that sends data to the New Relic collector 484 | # before shutting down. 485 | # send_data_on_exit: true 486 | 487 | # If true, the agent collects slow SQL queries. 488 | # slow_sql.enabled: false 489 | 490 | # If true, the agent collects explain plans in slow SQL queries. If this setting 491 | # is omitted, the transaction_tracer.explain.enabled setting will be applied as 492 | # the default setting for explain plans in slow SQL as well. 493 | # slow_sql.explain_enabled: false 494 | 495 | # Specify a threshold in seconds. The agent collects slow SQL queries and 496 | # explain plans that exceed this threshold. 497 | # slow_sql.explain_threshold: 1.0 498 | 499 | # Defines an obfuscation level for slow SQL queries. 500 | # Valid options are obfuscated, raw, or none. 501 | # slow_sql.record_sql: none 502 | 503 | # Generate a longer sql_id for slow SQL traces. sql_id is used for aggregation 504 | # of similar queries. 505 | # slow_sql.use_longer_sql_id: false 506 | 507 | # If true, the agent captures attributes on span events. 508 | # span_events_attributes.enabled: true 509 | 510 | # Defines the maximum number of span events reported from a single harvest. 511 | # This can be any integer between 1 and 10000. Increasing this value may impact 512 | # memory usage. 513 | # span_events.max_samples_stored: 2000 514 | 515 | # Prefix of attributes to exclude from span events. Allows * as wildcard at end. 516 | # span_events.attributes.exclude: [] 517 | 518 | # Prefix of attributes to include on span events. Allows * as wildcard at end. 519 | # span_events.attributes.include: [] 520 | 521 | # If true, enables span event sampling. 522 | # span_events.enabled: true 523 | 524 | # Sets the maximum number of span events to buffer when streaming to the trace 525 | # observer. 526 | # span_events.queue_size: 10000 527 | 528 | # Specify a list of exceptions you do not want the agent to strip when 529 | # strip_exception_messages is true. Separate exceptions with a comma. For 530 | # example, "ImportantException,PreserveMessageException". 531 | # strip_exception_messages.allowed_classes: "" 532 | 533 | # If true, the agent strips messages from all exceptions except those in the 534 | # allowlist. Enabled automatically in high security mode. 535 | # strip_exception_messages.enabled: true 536 | 537 | # When set to true, forces a synchronous connection to the New Relic collector 538 | # during application startup. For very short-lived processes, this helps ensure # the New Relic agent has time to report. 539 | # sync_startup: false 540 | 541 | # If true, enables use of the thread profiler. 542 | # thread_profiler.enabled: false 543 | 544 | # Defines the maximum number of seconds the agent should spend attempting to 545 | # connect to the collector. 546 | # timeout: 120 547 | 548 | # If true, the agent captures attributes from transaction events. 549 | # transaction_events_attributes.enabled: false 550 | 551 | # Prefix of attributes to exclude from transaction events. 552 | # Allows * as wildcard at end. 553 | # transaction_events.attributes.exclude: [] 554 | 555 | # Prefix of attributes to include in transaction events. 556 | # Allows * as wildcard at end. 557 | # transaction_events.attributes.include: [] 558 | 559 | # If true, the agent captures attributes on transaction segments. 560 | # transaction_segments_attributes.enabled: true 561 | 562 | # Prefix of attributes to exclude from transaction segments. 563 | # Allows * as wildcard at end. 564 | # transaction_segments.attributes.exclude: [] 565 | 566 | # Prefix of attributes to include on transaction segments. 567 | # Allows * as wildcard at end. 568 | # transaction_segments.attributes.include: [] 569 | 570 | # If true, the agent captures attributes from transaction traces. 571 | # transaction_tracer.attributes.enabled: false 572 | 573 | # Prefix of attributes to exclude from transaction traces. 574 | # Allows * as wildcard at end. 575 | # transaction_tracer.attributes.exclude: [] 576 | 577 | # Prefix of attributes to include in transaction traces. 578 | # Allows * as wildcard at end. 579 | # transaction_tracer.attributes.include: [] 580 | 581 | # If true, enables collection of transaction traces. 582 | # transaction_tracer.enabled: true 583 | 584 | # Threshold (in seconds) above which the agent will collect explain plans. 585 | # Relevant only when explain.enabled is true. 586 | # transaction_tracer.explain_threshold: 0.5 587 | 588 | # If true, enables the collection of explain plans in transaction traces. 589 | # This setting will also apply to explain plans in slow SQL traces if 590 | # slow_sql.explain enabled is not set separately. 591 | # transaction_tracer.explain.enabled: true 592 | 593 | # Maximum number of transaction trace nodes to record in a single transaction 594 | # trace. 595 | # transaction_tracer.limit_segments: 4000 596 | 597 | # If true, the agent records Redis command arguments in transaction traces. 598 | # transaction_tracer.record_redis_arguments: false 599 | 600 | # Obfuscation level for SQL queries reported in transaction trace nodes. 601 | # By default, this is set to obfuscated, which strips out the numeric and string 602 | # literals. If you do not want the agent to capture query information, set this 603 | # to 'none'. If you want the agent to capture all query information in its 604 | # original form, set this to 'raw'. When you enable high security mode this is 605 | # automatically set to 'obfuscated' 606 | # transaction_tracer.record_sql: 'obfuscated' 607 | 608 | # Specify a threshold in seconds. The agent includes stack traces in transaction 609 | # trace nodes when the stack trace duration exceeds this threshold. 610 | # transaction_tracer.stack_trace_threshold: 0.5 611 | 612 | # Specify a threshold in seconds. Transactions with a duration longer than this 613 | # threshold are eligible for transaction traces. Specify a float value or the 614 | # string apdex_f. 615 | # transaction_tracer.transaction_threshold: 1.0 616 | 617 | # If true, the agent automatically detects that it is running in an AWS 618 | # environment. 619 | # utilization.detect_aws: true 620 | 621 | # If true, the agent automatically detects that it is running in an Azure 622 | # environment. 623 | # utilization.detect_azure: true 624 | 625 | # If true, the agent automatically detects that it is running in Docker. 626 | # utilization.detect_docker: true 627 | 628 | # If true, the agent automatically detects that it is running in an Google Cloud 629 | # Platform environment. 630 | # utilization.detect_gcp: true 631 | 632 | # If true, the agent automatically detects that it is running in Kubernetes. 633 | # utilization.detect_kubernetes: true 634 | 635 | # If true, the agent automatically detects that it is running in a Pivotal Cloud Foundry environment. 636 | # utilization.detect_pcf: true 637 | 638 | # Environment-specific settings are in this section. 639 | # RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. 640 | # If your application has other named environments, configure them here. 641 | development: 642 | <<: *default_settings 643 | app_name: My application (Development) 644 | 645 | test: 646 | <<: *default_settings 647 | # It doesn't make sense to report to New Relic from automated test runs. 648 | monitor_mode: false 649 | 650 | staging: 651 | <<: *default_settings 652 | app_name: My application (Staging) 653 | 654 | production: 655 | <<: *default_settings 656 | -------------------------------------------------------------------------------- /deploy/activate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "$0: This activate script is a no-op" 4 | -------------------------------------------------------------------------------- /dockerfiles/ci.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/ruby:3.3.4-bullseye 2 | 3 | RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - 4 | 5 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 6 | && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list 7 | 8 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ 9 | && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 10 | 11 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 12 | && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list 13 | 14 | 15 | RUN apt-get update -qq 16 | RUN apt-get install -y --no-install-recommends nodejs \ 17 | locales \ 18 | yarn 19 | RUN curl -L -o cf8-cli_linux_x86-64.tgz "https://packages.cloudfoundry.org/stable?release=linux64-binary&version=v8&source=github" && \ 20 | tar -xvzf cf8-cli_linux_x86-64.tgz && \ 21 | mv cf8 /usr/local/bin && \ 22 | cf8 --version 23 | 24 | RUN find / -perm /6000 -type f -exec chmod a-s {} \; || true 25 | 26 | -------------------------------------------------------------------------------- /lib/tasks/write_deploy_json.rake: -------------------------------------------------------------------------------- 1 | namespace :login do 2 | desc 'generate a generic deploy.json file' 3 | task :deploy_json do 4 | puts 'Writing deploy.json' 5 | data = { 6 | env: ENV['environment'] || 'unknown', 7 | branch: `git branch --no-color 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/\1/'`. 8 | chomp[2..], 9 | user: 'n/a', 10 | git_sha: `git rev-parse HEAD`.chomp, 11 | git_date: Time.at(`git show -s --format=%ct HEAD`.chomp.to_i).iso8601, 12 | cloud_gov_deploy_timestamp: DateTime.now.strftime('%Y%m%d%H%M%S'), 13 | fqdn: 'n/a', 14 | instance_id: 'n/a', 15 | } 16 | 17 | # set deprecated attribute names 18 | data[:sha] = data[:git_sha] 19 | data[:timestamp] = data[:cloud_gov_deploy_timestamp] 20 | 21 | File.write('public/api/deploy.json', JSON.generate(data)) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /openid_configuration.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require_relative './config' 3 | 4 | module LoginGov 5 | module OidcSinatra 6 | class OpenidConfiguration 7 | def self.cached 8 | @cached ||= live 9 | end 10 | 11 | def self.live 12 | config = Config.new 13 | begin 14 | response = Faraday.get(URI.join(config.idp_url, '/.well-known/openid-configuration')) 15 | 16 | if response.status == 200 17 | JSON.parse(response.body).with_indifferent_access 18 | else 19 | msg = 'Error: Unable to retrieve OIDC configuration from IdP.' 20 | msg += " #{config.idp_url} responded with #{response.status}." 21 | 22 | if response.status == 401 23 | msg += ' Perhaps we need to reimplement HTTP Basic Auth.' 24 | end 25 | 26 | raise AppError.new(msg) 27 | end 28 | end 29 | end 30 | 31 | def self.cached_idp_public_key(openid_configuration) 32 | @cached_idp_public_key ||= live_idp_public_key(openid_configuration) 33 | end 34 | 35 | def self.live_idp_public_key(openid_configuration) 36 | certs_response = JSON.parse( 37 | Faraday.get(openid_configuration[:jwks_uri]).body, 38 | ).with_indifferent_access 39 | 40 | JSON::JWK.new(certs_response[:keys].first).to_key 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "identity-oidc-sinatra", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "CC0-1.0", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node --require=jsdom-global/register --test spec/**/*.spec.js" 9 | }, 10 | "dependencies": { 11 | "@uswds/uswds": "3.8.2" 12 | }, 13 | "devDependencies": { 14 | "jsdom": "^25.0.1", 15 | "jsdom-global": "^3.0.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /public/api/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/identity-oidc-sinatra/e4f731a764f6ed2238782de6eaecb49427dbfb9f/public/api/.keep -------------------------------------------------------------------------------- /public/assets/css/fake.css: -------------------------------------------------------------------------------- 1 | .bg-maroon { 2 | background-color: #85144b; 3 | } 4 | .bg-navy { 5 | background-color: #112e51; 6 | } 7 | 8 | .userinfo { 9 | border: 1px solid black; 10 | color: #000000; 11 | font-size: 0.6em; 12 | margin-top: .5rem; 13 | position: absolute; 14 | right: 360px; 15 | top: 285px; 16 | } 17 | .userinfo pre { 18 | margin: 0; 19 | } 20 | @media (max-width: 63.99em) { 21 | .userinfo { 22 | position: initial; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/assets/css/sign-in.css: -------------------------------------------------------------------------------- 1 | .sign-in-wrap { 2 | padding-top: 10px; 3 | clear: both; 4 | } 5 | 6 | .sign-in-bttn svg { 7 | margin-bottom: -2px; 8 | margin-right: 3px; 9 | height: 1rem; 10 | } 11 | 12 | .usa-nav__secondary { 13 | padding-bottom: 10px; 14 | } 15 | 16 | .details-popup { 17 | position: absolute; 18 | margin-top: 5px; 19 | display: block; 20 | right: 10px; 21 | } 22 | 23 | .details-popup summary { 24 | text-align: right; 25 | } 26 | 27 | .details-popup[open] { 28 | width: 120%; 29 | background: white; 30 | border: 2px solid black; 31 | border-radius: 3px; 32 | padding: 10px; 33 | box-shadow: 0 0 10px #fff; 34 | z-index: 1; 35 | } 36 | 37 | .details-popup .usa-select { 38 | width: 70%; 39 | } 40 | 41 | @media all and (max-width: 63.99em) { 42 | .details-popup[open] { 43 | width: initial; 44 | right: 0; 45 | margin: 5px; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/assets/img/GitHub-Mark-120px-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/identity-oidc-sinatra/e4f731a764f6ed2238782de6eaecb49427dbfb9f/public/assets/img/GitHub-Mark-120px-plus.png -------------------------------------------------------------------------------- /public/assets/js/scope-element.js: -------------------------------------------------------------------------------- 1 | class ScopeElement extends HTMLElement { 2 | connectedCallback() { 3 | this.syncCheckedToIAL(); 4 | this.ial.addEventListener('input', () => this.syncCheckedToIAL()); 5 | } 6 | 7 | /** 8 | * @return {HTMLInputElement} 9 | */ 10 | get checkbox() { 11 | return this.querySelector('[type=checkbox]'); 12 | } 13 | 14 | /** 15 | * @return {HTMLSelectElement} 16 | */ 17 | get ial() { 18 | return this.ownerDocument.getElementById(this.getAttribute('ial-element')); 19 | } 20 | 21 | /** 22 | * @return {Record} 23 | */ 24 | get defaultScopesByIAL() { 25 | const defaultScopes = this.ownerDocument.getElementById( 26 | this.getAttribute('default-scopes-element') 27 | ); 28 | return JSON.parse(defaultScopes.textContent); 29 | } 30 | 31 | syncCheckedToIAL() { 32 | const defaultScopes = this.defaultScopesByIAL[this.ial.value]; 33 | this.checkbox.checked = defaultScopes.includes(this.checkbox.value); 34 | } 35 | } 36 | 37 | if (!window.customElements.get('lg-scope')) { 38 | window.customElements.define('lg-scope', ScopeElement); 39 | } 40 | -------------------------------------------------------------------------------- /spec/app_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'nokogiri' 3 | require 'securerandom' 4 | require 'cgi' 5 | require 'byebug' 6 | 7 | RSpec.describe LoginGov::OidcSinatra::OpenidConnectRelyingParty do 8 | let(:host) { 'http://localhost:3000' } 9 | let(:authorization_endpoint) { "#{host}/openid/authorize" } 10 | let(:token_endpoint) { "#{host}/api/openid/token" } 11 | let(:userinfo_endpoint) { "#{host}/api/openid/userinfo" } 12 | let(:end_session_endpoint) { "#{host}/openid/logout" } 13 | let(:jwks_endpoint) { "#{host}/api/openid_connect/certs" } 14 | let(:client_id) { 'urn:gov:gsa:openidconnect:sp:sinatra' } 15 | let(:vtr_disabled) { false } 16 | let(:idp_private_key) { OpenSSL::PKey::RSA.new(read_fixture_file('idp.key')) } 17 | let(:nonce) { 'abc' } 18 | 19 | before do 20 | ENV['semantic_ial_values_enabled'] = 'false' 21 | ENV['PKCE'] = 'false' 22 | allow_any_instance_of(LoginGov::OidcSinatra::Config).to receive(:cache_oidc_config?).and_return(false) 23 | allow_any_instance_of(LoginGov::OidcSinatra::Config).to receive(:vtr_disabled?).and_return(vtr_disabled) 24 | stub_request(:get, "#{host}/.well-known/openid-configuration"). 25 | to_return(body: { 26 | authorization_endpoint: authorization_endpoint, 27 | token_endpoint: token_endpoint, 28 | userinfo_endpoint: userinfo_endpoint, 29 | end_session_endpoint: end_session_endpoint, 30 | jwks_uri: jwks_endpoint, 31 | }.to_json) 32 | 33 | stub_request(:get, jwks_endpoint). 34 | to_return(body: { keys: [{ 35 | alg: 'RS256', 36 | use: 'sig',}.merge(JWT::JWK.new(OpenSSL::PKey::RSA.new(read_fixture_file('idp.key.pub'))).export)], 37 | }.to_json, 38 | ) 39 | end 40 | 41 | context '/' do 42 | it 'pre-fills IAL2 if the URL has ?ial=2 (used in smoke tests)' do 43 | get '/?ial=2' 44 | 45 | expect(last_response).to be_ok 46 | 47 | doc = Nokogiri::HTML(last_response.body) 48 | ial2_option = doc.at('select[name=ial] option[value=2]') 49 | expect(ial2_option[:selected]).to be 50 | end 51 | 52 | it 'pre-fills HSPD12 if the URL has ?aal=2-hspd12' do 53 | get '/?aal=2-hspd12' 54 | 55 | expect(last_response).to be_ok 56 | 57 | doc = Nokogiri::HTML(last_response.body) 58 | aal3_option = doc.at('select[name=aal] option[value="2-hspd12"]') 59 | expect(aal3_option[:selected]).to be 60 | end 61 | 62 | it 'renders an error if basic auth credentials are wrong' do 63 | stub_request(:get, "#{host}/.well-known/openid-configuration"). 64 | to_return(body: '', status: 401) 65 | 66 | get '/' 67 | 68 | expect(last_response.body).to include( 69 | 'Perhaps we need to reimplement HTTP Basic Auth', 70 | ) 71 | end 72 | 73 | it 'renders an error if the app fails to get oidc configuration' do 74 | stub = stub_request(:get, "#{host}/.well-known/openid-configuration"). 75 | to_return(body: '', status: 400) 76 | 77 | get '/' 78 | 79 | error_string = "Error: Unable to retrieve OIDC configuration from IdP. #{host} responded with 400." 80 | expect(last_response.body).to include(error_string) 81 | expect(stub).to have_been_requested.once 82 | expect(last_response.status).to eq 500 83 | end 84 | end 85 | 86 | context '/auth/request' do 87 | let(:params) {} 88 | 89 | shared_examples 'redirects to IDP with legacy IAL1' do 90 | it 'sends the correct acr_values and scopes' do 91 | get request_path, **params 92 | 93 | expect(last_response).to be_redirect 94 | 95 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 96 | expect(scope).to include('openid', 'email', 'x509') 97 | expect(acr_values).to include('http://idmanagement.gov/ns/assurance/ial/1') 98 | end 99 | end 100 | 101 | shared_examples 'redirects to IDP with legacy IAL2' do 102 | it 'sends the correct acr_values and scopes' do 103 | get request_path, **params 104 | 105 | expect(last_response).to be_redirect 106 | 107 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 108 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 109 | expect(acr_values).to include('http://idmanagement.gov/ns/assurance/ial/2') 110 | end 111 | end 112 | 113 | shared_examples 'redirects to IDP with legacy IAL0' do 114 | it 'sends the correct acr_values and scopes' do 115 | get request_path, **params 116 | 117 | expect(last_response).to be_redirect 118 | 119 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 120 | expect(scope).to include('openid', 'email', 'social_security_number', 'x509') 121 | expect(acr_values).to include('http://idmanagement.gov/ns/assurance/ial/0') 122 | end 123 | end 124 | 125 | shared_examples 'redirects to IDP with legacy IAL2 and bio=preferred' do 126 | it 'sends the correct acr_values and scopes' do 127 | get request_path, **params 128 | 129 | expect(last_response).to be_redirect 130 | 131 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 132 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 133 | expect(acr_values).to include('http://idmanagement.gov/ns/assurance/ial/2?bio=preferred') 134 | end 135 | end 136 | 137 | shared_examples 'redirects to IDP with semantic verified-facial-match-preferred' do 138 | it 'sends the correct acr_values and scopes' do 139 | get request_path, **params 140 | 141 | expect(last_response).to be_redirect 142 | 143 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 144 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 145 | expect(acr_values).to include('urn:acr.login.gov:verified-facial-match-preferred') 146 | end 147 | end 148 | 149 | shared_examples 'redirects to IDP with legacy IAL2 and bio=required' do 150 | it 'sends the correct acr_values and scopes' do 151 | get request_path, **params 152 | 153 | expect(last_response).to be_redirect 154 | 155 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 156 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 157 | expect(acr_values).to include('http://idmanagement.gov/ns/assurance/ial/2?bio=required') 158 | end 159 | end 160 | 161 | shared_examples 'redirects to IDP with semantic verified-facial-match-required' do 162 | it 'sends the correct acr_values and scopes' do 163 | get request_path, **params 164 | 165 | expect(last_response).to be_redirect 166 | 167 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 168 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 169 | expect(acr_values).to include('urn:acr.login.gov:verified-facial-match-required') 170 | end 171 | end 172 | 173 | shared_examples 'redirects to IDP with semantic verified' do 174 | it 'sends the correct acr_values and scopes' do 175 | get request_path, **params 176 | 177 | expect(last_response).to be_redirect 178 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 179 | 180 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 181 | expect(acr_values).to include('urn:acr.login.gov:verified') 182 | end 183 | end 184 | 185 | shared_examples 'redirects to IDP with semantic auth-only' do 186 | it 'sends the correct acr_values and scopes' do 187 | get request_path, **params 188 | 189 | expect(last_response).to be_redirect 190 | 191 | scope, acr_values = extract_scope_and_acr_values(last_response.location) 192 | expect(scope).to include('openid', 'email', 'x509') 193 | expect(acr_values).to include('urn:acr.login.gov:auth-only') 194 | end 195 | end 196 | 197 | shared_examples 'PKCE auth request' do 198 | it 'sends the PKCE parameters' do 199 | get request_path, **params 200 | 201 | expect(last_response).to be_redirect 202 | expect(parameter_value(last_response.location, 'client_id')).to eq('urn:gov:gsa:openidconnect:sp:sinatra_pkce') 203 | expect(parameter_value(last_response.location, 'code_challenge')).to eq('CODE_CHALLENGE') 204 | expect(parameter_value(last_response.location, 'code_challenge_method')).to eq('S256') 205 | end 206 | end 207 | 208 | context 'with PKCE enabled' do 209 | before do 210 | ENV['PKCE'] = 'true' 211 | allow_any_instance_of(LoginGov::OidcSinatra::OpenidConnectRelyingParty).to receive(:url_safe_code_challenge).and_return('CODE_CHALLENGE') 212 | end 213 | 214 | let(:request_path) { '/auth/request' } 215 | 216 | it_behaves_like 'PKCE auth request' 217 | end 218 | 219 | context 'with vtr disabled' do 220 | let(:vtr_disabled) { true } 221 | 222 | context 'when there is no ial parameter' do 223 | let(:request_path) { '/auth/request' } 224 | let(:params) { { requested_scopes: %w[openid email x509] } } 225 | 226 | it_behaves_like 'redirects to IDP with legacy IAL1' 227 | 228 | context 'when semantic ial values are enabled' do 229 | before do 230 | ENV['semantic_ial_values_enabled'] = 'true' 231 | end 232 | 233 | it_behaves_like 'redirects to IDP with semantic auth-only' 234 | end 235 | end 236 | 237 | context 'when the ial parameter is 2' do 238 | let(:request_path) { '/auth/request?ial=2' } 239 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 240 | 241 | it_behaves_like 'redirects to IDP with legacy IAL2' 242 | 243 | context 'when semantic ial values are enabled' do 244 | before do 245 | ENV['semantic_ial_values_enabled'] = 'true' 246 | end 247 | 248 | it_behaves_like 'redirects to IDP with semantic verified' 249 | end 250 | end 251 | 252 | context 'when the ial parameter is 1' do 253 | let(:request_path) { '/auth/request?ial=1' } 254 | let(:params) { { requested_scopes: %w[openid email x509] } } 255 | 256 | it_behaves_like 'redirects to IDP with legacy IAL1' 257 | 258 | context 'when semantic ial values are enabled' do 259 | before do 260 | ENV['semantic_ial_values_enabled'] = 'true' 261 | end 262 | 263 | it_behaves_like 'redirects to IDP with semantic auth-only' 264 | end 265 | end 266 | 267 | context 'when the ial parameter is 0' do 268 | let(:request_path) { '/auth/request?ial=0' } 269 | let(:params) { { requested_scopes: %w[openid email social_security_number x509] } } 270 | 271 | it_behaves_like 'redirects to IDP with legacy IAL0' 272 | 273 | context 'when semantic ial values are enabled' do 274 | before do 275 | ENV['semantic_ial_values_enabled'] = 'true' 276 | end 277 | 278 | it_behaves_like 'redirects to IDP with legacy IAL0' 279 | end 280 | end 281 | 282 | context 'when the ial parameter is step-up' do 283 | let(:request_path) { '/auth/request?ial=step-up' } 284 | let(:params) { { requested_scopes: %w[openid email x509] } } 285 | 286 | it_behaves_like 'redirects to IDP with legacy IAL1' 287 | 288 | context 'when semantic ial values are enabled' do 289 | before do 290 | ENV['semantic_ial_values_enabled'] = 'true' 291 | end 292 | 293 | it_behaves_like 'redirects to IDP with semantic auth-only' 294 | end 295 | end 296 | 297 | context 'when the aal parameter is 2-phishing_resistant' do 298 | let(:request_path) { '/auth/request?aal=2-phishing_resistant' } 299 | it 'redirects to IDP with legacy AAL2 and phishing_resistant=true' do 300 | get request_path 301 | 302 | expect(last_response).to be_redirect 303 | 304 | _, acr_values = extract_scope_and_acr_values(last_response.location) 305 | expect(acr_values).to include( 306 | 'http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true', 307 | ) 308 | end 309 | end 310 | 311 | context 'when the aal parameter is 2-hspd12' do 312 | let(:request_path) { '/auth/request?aal=2-hspd12' } 313 | it 'redirects to IDP with legacy AAL2 and hspd12=true' do 314 | get request_path 315 | 316 | expect(last_response).to be_redirect 317 | 318 | _, acr_values = extract_scope_and_acr_values(last_response.location) 319 | expect(acr_values).to include( 320 | 'http://idmanagement.gov/ns/assurance/aal/2?hspd12=true', 321 | ) 322 | end 323 | end 324 | 325 | context 'when the ial parameter is facial-match-required' do 326 | let(:request_path) { '/auth/request?ial=facial-match-required' } 327 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 328 | 329 | it_behaves_like 'redirects to IDP with legacy IAL2 and bio=required' 330 | 331 | context 'when semantic ial values are enabled' do 332 | before do 333 | ENV['semantic_ial_values_enabled'] = 'true' 334 | end 335 | 336 | it_behaves_like 'redirects to IDP with semantic verified-facial-match-required' 337 | end 338 | end 339 | 340 | context 'when the ial parameter is facial-match-preferred' do 341 | let(:request_path) { '/auth/request?ial=facial-match-preferred' } 342 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 343 | 344 | it_behaves_like 'redirects to IDP with legacy IAL2 and bio=preferred' 345 | 346 | context 'when semantic ial values are enabled' do 347 | before do 348 | ENV['semantic_ial_values_enabled'] = 'true' 349 | end 350 | 351 | it_behaves_like 'redirects to IDP with semantic verified-facial-match-preferred' 352 | end 353 | end 354 | end 355 | 356 | context 'with vtr enabled' do 357 | let(:vtr_disabled) { false } 358 | 359 | context 'when the ial is enhanced-ipp-required' do 360 | context 'when eipp is not allowed' do 361 | let(:request_path) { '/auth/request?ial=enhanced-ipp-required' } 362 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 363 | 364 | it 'does not set a vtr value' do 365 | get request_path, **params 366 | 367 | expect(last_response).to be_redirect 368 | 369 | scope, vtr = extract_scope_and_vtr(last_response.location) 370 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 371 | expect(vtr).to be nil 372 | end 373 | end 374 | 375 | context 'when eipp is allowed' do 376 | before { ENV['eipp_allowed'] = 'true' } 377 | after { ENV['eipp_allowed'] = 'false' } 378 | 379 | let(:request_path) { '/auth/request?ial=enhanced-ipp-required' } 380 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 381 | 382 | it 'redirects to IDP with vtr=["C1.P1.Pe"]' do 383 | get request_path, **params 384 | 385 | expect(last_response).to be_redirect 386 | 387 | scope, vtr = extract_scope_and_vtr(last_response.location) 388 | expect(scope).to include('openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 389 | expect(vtr).to include('C1.P1.Pe') 390 | end 391 | end 392 | end 393 | 394 | context 'when the ial is facial-match-vot' do 395 | let(:request_path) { '/auth/request?ial=facial-match-vot' } 396 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 397 | 398 | it 'redirects to IDP with vtr=["C1.P1.Pb"]' do 399 | get request_path, **params 400 | 401 | expect(last_response).to be_redirect 402 | 403 | scope, vtr = extract_scope_and_vtr(last_response.location) 404 | expect(scope).to include( 'openid', 'email', 'profile', 'social_security_number', 'phone', 'address', 'x509') 405 | expect(vtr).to include('C1.P1.Pb') 406 | end 407 | end 408 | 409 | context 'when the ial parameter is 2' do 410 | let(:request_path) { '/auth/request?ial=2' } 411 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 412 | 413 | it_behaves_like 'redirects to IDP with legacy IAL2' 414 | 415 | context 'when semantic ial values are enabled' do 416 | before do 417 | ENV['semantic_ial_values_enabled'] = 'true' 418 | end 419 | 420 | it_behaves_like 'redirects to IDP with semantic verified' 421 | end 422 | end 423 | 424 | context 'when the ial parameter is facial-match-required' do 425 | let(:request_path) { '/auth/request?ial=facial-match-required' } 426 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 427 | 428 | it_behaves_like 'redirects to IDP with legacy IAL2 and bio=required' 429 | 430 | context 'when semantic ial values are enabled' do 431 | before do 432 | ENV['semantic_ial_values_enabled'] = 'true' 433 | end 434 | 435 | it_behaves_like 'redirects to IDP with semantic verified-facial-match-required' 436 | end 437 | end 438 | 439 | context 'when the ial parameter is facial-match-preferred' do 440 | let(:request_path) { '/auth/request?ial=facial-match-preferred' } 441 | let(:params) { { requested_scopes: %w[openid email profile social_security_number phone address x509] } } 442 | 443 | it_behaves_like 'redirects to IDP with legacy IAL2 and bio=preferred' 444 | 445 | context 'when semantic ial values are enabled' do 446 | before do 447 | ENV['semantic_ial_values_enabled'] = 'true' 448 | end 449 | 450 | it_behaves_like 'redirects to IDP with semantic verified-facial-match-preferred' 451 | end 452 | end 453 | end 454 | end 455 | 456 | context '/auth/result' do 457 | context 'when errors happen before the auth token exchange' do 458 | context 'when access is denied' do 459 | it 'redirects to root with an access_denied error parameter' do 460 | get '/auth/result', error: 'access_denied' 461 | 462 | expect(last_response).to be_redirect 463 | uri = URI.parse(last_response.location) 464 | expect(uri.path).to eq('/') 465 | expect(uri.query).to eq('error=access_denied') 466 | follow_redirect! 467 | expect(last_response.body).to include('You chose to exit before signing in') 468 | end 469 | end 470 | 471 | context 'when there is no code parameter' do 472 | it 'errors with a missing code parameter message' do 473 | get '/auth/result' 474 | 475 | doc = Nokogiri::HTML(last_response.body) 476 | 477 | expect(doc.text).to include('missing callback param: code') 478 | end 479 | end 480 | end 481 | 482 | context 'when the token exchange takes place' do 483 | let(:code) { 'abc-code' } 484 | let(:connection) { double Faraday } 485 | 486 | let(:email) { 'foobar@bar.com' } 487 | let(:bearer_token) { 'abc' } 488 | 489 | context 'with valid token' do 490 | it 'takes an authorization code and gets a token, and renders the email from the token' do 491 | get '/auth/request' 492 | 493 | stub_token_response( 494 | code:, 495 | bearer_token: bearer_token, 496 | id_token: generate_id_token(nonce: last_request.session['nonce']), 497 | ) 498 | stub_userinfo_response(bearer_token: bearer_token, email: email) 499 | 500 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 501 | 502 | expect(last_response).to be_redirect 503 | follow_redirect! 504 | expect(last_response.body).to include(email) 505 | end 506 | 507 | context 'with dangerous input' do 508 | let(:email) { ' mallory@bar.com' } 509 | 510 | it 'escapes dangerous HTML' do 511 | get '/auth/request' 512 | stub_token_response( 513 | code:, 514 | bearer_token: bearer_token, 515 | id_token: generate_id_token(nonce: last_request.session['nonce']), 516 | ) 517 | stub_userinfo_response(bearer_token: bearer_token, email: email) 518 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 519 | 520 | follow_redirect! 521 | 522 | expect(last_response.body).to_not include(email) 523 | expect(last_response.body).to include('<script>alert("hi")</script> mallory@bar.com') 524 | end 525 | end 526 | 527 | it 'has a logout link to the handle-logout endpoint' do 528 | get '/auth/request' 529 | stub_token_response( 530 | code:, 531 | bearer_token: bearer_token, 532 | id_token: generate_id_token(nonce: last_request.session['nonce']), 533 | ) 534 | stub_userinfo_response(bearer_token: bearer_token, email: email) 535 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 536 | follow_redirect! 537 | doc = Nokogiri::HTML(last_response.body) 538 | 539 | 540 | logout_form = doc.at_css('form') 541 | expect(logout_form).not_to be_nil 542 | 543 | expect(logout_form[:action]).to eq '/handle-logout' 544 | expect(logout_form[:method]).to eq 'post' 545 | end 546 | end 547 | 548 | context 'with invalid state' do 549 | it 'fails auth and shows error message' do 550 | get '/auth/request' 551 | get '/auth/result', { code:, state: 'fake-state' }, 'rack.session' => last_request.session 552 | 553 | expect(last_response.body).to include('invalid state') 554 | expect(last_response.body).to_not include(email) 555 | end 556 | end 557 | 558 | context 'with invalid nonce' do 559 | it 'fails auth and shows error message' do 560 | get '/auth/request' 561 | stub_token_response( 562 | code:, 563 | bearer_token: bearer_token, 564 | id_token: generate_id_token(nonce: 'fake-nonce'), 565 | ) 566 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 567 | 568 | expect(last_response.body).to include('invalid nonce') 569 | expect(last_response.body).to_not include(email) 570 | end 571 | end 572 | 573 | context 'with PKCE enabled' do 574 | before do 575 | ENV['PKCE'] = 'true' 576 | allow_any_instance_of(LoginGov::OidcSinatra::OpenidConnectRelyingParty).to receive(:url_safe_code_challenge).and_return('CODE_CHALLENGE') 577 | end 578 | 579 | context 'with valid token' do 580 | context 'when the code_verifier is valid' do 581 | it 'takes an authorization code and gets a token, and renders the email from the token' do 582 | get '/auth/request' 583 | 584 | stub_request(:post, token_endpoint). 585 | with(body: { 586 | grant_type: 'authorization_code', 587 | code:, 588 | code_verifier: last_request.session['code_verifier'], 589 | }). 590 | to_return(body: { access_token: bearer_token, id_token: generate_id_token(nonce: last_request.session['nonce'])}.to_json) 591 | 592 | stub_userinfo_response(bearer_token:, email:) 593 | 594 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 595 | 596 | expect(last_response).to be_redirect 597 | follow_redirect! 598 | expect(last_response.body).to include(email) 599 | end 600 | end 601 | 602 | context 'when the code_verifier is invalid' do 603 | it 'takes an authorization code and gets a token, and renders the email from the token' do 604 | get '/auth/request' 605 | 606 | stub_request(:post, token_endpoint). 607 | with(body: { 608 | grant_type: 'authorization_code', 609 | code:, 610 | code_verifier: kind_of(String), 611 | }). 612 | to_return(status: 400, body: {'error': 'Code verifier code_verifier did not match code_challenge'}.to_json) 613 | 614 | get '/auth/result', { code:, state: last_request.session['state'] }, 'rack.session' => last_request.session 615 | 616 | expect(last_response.body).to include('Code verifier code_verifier did not match code_challenge') 617 | expect(last_response.body).to_not include(email) 618 | end 619 | end 620 | end 621 | end 622 | end 623 | end 624 | 625 | context 'POST /handle-logout' do 626 | let(:redirect_uri) { 'http://localhost:9292/logout' } 627 | 628 | before do 629 | get '/' 630 | last_request.session[:userinfo] = 'userinfo' 631 | last_request.session[:email] = 'user@example.com' 632 | last_request.session[:step_up_enabled] = false 633 | last_request.session[:step_up_aal] = false 634 | last_request.session[:irs] = false 635 | last_request.session[:state] = 'abc123' 636 | 637 | post '/handle-logout', authenticity_token: last_request.session[:csrf] 638 | end 639 | 640 | it 'deletes the session objects' do 641 | expect(last_request.session.keys).to_not include('userinfo') 642 | expect(last_request.session.keys).to_not include('email') 643 | expect(last_request.session.keys).to_not include('step_up_enabled') 644 | expect(last_request.session.keys).to_not include('step_up_aal') 645 | expect(last_request.session.keys).to_not include('irs') 646 | end 647 | 648 | it 'redirects to Login.gov logout' do 649 | expect(last_response.location).to include(end_session_endpoint) 650 | expect(parameter_value(last_response.location, 'client_id')).to eq(client_id) 651 | expect(parameter_value(last_response.location, 'post_logout_redirect_uri')).to eq(redirect_uri) 652 | end 653 | end 654 | 655 | def generate_id_token(nonce:) 656 | JWT.encode({ nonce: nonce }, idp_private_key, 'RS256', kid: JWT::JWK.new(idp_private_key)) 657 | end 658 | 659 | def stub_token_response(code:, bearer_token:, id_token: ) 660 | stub_request(:post, token_endpoint). 661 | with(body: { 662 | grant_type: 'authorization_code', 663 | code:, 664 | client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 665 | client_assertion: kind_of(String), 666 | }). 667 | to_return(body: { access_token: bearer_token, id_token: id_token }.to_json) 668 | end 669 | 670 | def stub_userinfo_response(bearer_token:, email: ) 671 | stub_request(:get, userinfo_endpoint). 672 | with(headers: {'Authorization' => "Bearer #{bearer_token}" }). 673 | to_return(body: { email: email }.to_json) 674 | end 675 | 676 | def parameter_value(url, parameter_name) 677 | params = CGI.parse(URI(url).query) 678 | params[parameter_name].first 679 | end 680 | 681 | def extract_scope_and_acr_values(url) 682 | params = CGI.parse(URI(url).query) 683 | [params['scope'].first.split, params['acr_values'].first.split] 684 | end 685 | 686 | def extract_scope_and_vtr(url) 687 | params = CGI.parse(URI(url).query) 688 | [params['scope'].first.split, params['vtr'].first] 689 | end 690 | end 691 | -------------------------------------------------------------------------------- /spec/fixtures/idp.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKgIBAAKCAgEA0Yt2XvyXN1uRtxjqG3reI8v8x+QIdIc7FaLOROZ/nyaxVsHZ 3 | 4kiSJ0aZ29yNRrk0IkUfJiLBChiH5STpBv/N/K4XVudUt3Ff8u+grrgxMM/DhE0E 4 | lSpCzBPl+jePONPakQO7fG8xTjiHPJK5RxqzJog6dAv4foHp8ps38XQlPCbbocoH 5 | oDF+sgiFkmCeIpKmXCH2SRWCMo+9Em8r4TzpzD1+wadpwYtljIuF6Hpm0yy4hW0/ 6 | 9upMHlzQAmrAM7R+L30SF+x+3Ex6Y6qusmHMRq7oXJns/3xsgv6KihWroS8eSaQo 7 | GrddE8Ez6DhBmff93qcsxgnPGs2moM40XqfZn7uZ1YWQLYQdnEjLqngaKbqRlg1Z 8 | 1wp9HBh9BohsCroLoWssC6rGsJM96brsiCFlWPJ49fImmJY9r4RTGX/HO7+Bpfv/ 9 | UXTcQFmZjWFFYHFjqYQZ/bZROAxlL+OqDTxAspu9desJiYgIZQodvjmVJCQ8aHUg 10 | S2lvszq1pIeo0KR/+PRISsvXWDFupSirW+qDVKKtbDAYje+VuRq3Li7p4y2Ut9Oz 11 | f+2SMI7E86NsQqRK5ixCtq845wHEUsClzxVbeCa2vil2x0/D9us0qR8TulJ7gCpo 12 | Rednt48rG9zDU6naRrEw2qByrnhTqV7XtDoFc/Kaxz1xYvIAelc2DctTAlsCAwEA 13 | AQKCAgEAnJ2nGHGkOAzpyTcG6wkXdRvm0CRAqY/VemNX1imNY4+Q5m43AqkJC+/5 14 | 2VlsAls0exS8pk09iOUy1PKUdNXGyL/508tNB1rmwvWVXGFw6rGuyXijHzYZ/Nw2 15 | fKEAHBJD/zUR8XxOFs+rTHvBnUJ4eilBvzCfRzake97FnmMX3XCRocgVkQbNUhWK 16 | eBBcwSxoyN3dbVx1FoNWZqvO2Sck+7FOx0Dwi2c7vCMgL9Uke1umgptYPp15zQvD 17 | sLrG8x5JXgcV+emaG9/RbiLJNaxg+MeMBoJJn98IX5QOSgzOXceQ1KusCePY3oX9 18 | 3OgHKCQxjDPZBB+ausbQbsoQXFsKDL3zDhjXqZC+X61l5CXF1/IvpwWuxjOAfzhs 19 | nqbfR4C0ZxCNdPIhFwbw0AhPs3G2FqKjCyYEetFuOn5Vb6/UO0zLMHzlSHT9dXJz 20 | Mz9Ngb+0LliJgN+tDRHsvCMtnB+XastA25mHltdXcGa3X6lEVIBrbLvurUjs3aGX 21 | zrjskAQOYcYIfsA+LaRWyg/aPaBPEdSTfXS4jNsrHTxC23lHVB+X95TZVXn8IXyr 22 | k4Z+m92BxpqhhjYBHrjbjc/pIcKysp6XnLfNnQz9z4QmSt16ziKd9Sm8rFSoDHPy 23 | agmTHS0t0ocn+Zfsl5wddWLMYgd48LS5M7lpsD4Qf1UZcrtMwPECggEBAOrBr2Xx 24 | eHG+jkOa1d22pCicrMleIZOSpXGwIQ8OU6KIVmC5OlENzTkS258XPMyaFKH7M+zA 25 | 8bl8wSdjOxpwsxruIkDvGg28eE+rjpLo8Cc7i62fXnRjYj96Sas7Ddh4Cvuv7NGV 26 | ipMwpusmcTkGYAnlz5eA00k5sicgfs7gIUDtuxS1wh7d0MWAvE5YGdQBOVHpmUAc 27 | n3Ewa8JXKOqKii2a5UgtG1vZdyHzAOD2W+r7HxOo8MfHrLoFHBcmC3FD73wmbESE 28 | sBEWrvRTevR9EmSvTIjF8BrNaylqIFJh8yo91Vv5W7JfU5DekV83qm76UTyIYYQQ 29 | C8TCqki3HHF4n4MCggEBAOSBuf3pW5G3Obv49V0BgQCQIvOlRDLZntz3dCpO4t9n 30 | XW6yXJ4xG6D+8fw/LjFDSCg9bFiSBiNnDgVi5klDQ4JbaKMaGPaiNkMSGFF62FFx 31 | Si3ibL6PoznETX90g9gaUajTDdHB+G3bODDfXMLog2hku8U0F/10i2IK9P+WoL3a 32 | gwvn3Wfnfl3vrza8MdDJJFJ/ltV3P5opyGre1+rw5JLvDirqzdn3gWPDOYoABn0d 33 | 8GnCXy79GGykp7m7dfVGYXpZkqAI/kPiDGaBBYHFKrcFMhdaK4Wd+igJgLpLWXEC 34 | QgJBzauEhc2blTV+bcXn/N6Lel67qyTQEu87t7zPgkkCggEBANI6CtmvCV5K+Fmg 35 | Uf9PrOhVjgJyUn02KQSLZr914/28PCY4Gfo18t82fQiWJbNQFEQOkixliNLD3rFo 36 | EqiV3j6ynGgr9tfwPImUJ4R2J4HquCYOfuaZjYUs/MXjh32f2q4TmG8DjOpM+l3A 37 | ukoH5H9YlrOlARElIt4ZIpYebokXm55rOmCr4WbM51T9XnvVcMBjAgNA3qPdadLK 38 | fPC0ihNXEBa3ljWFpEL5u4K5/AyNQAI6Gw29SXf4VXSBdGPFI7S5L+GYNZfICXrx 39 | Oz2wt4UE2vPs1h0acenPiQxdfQYKA4Ru5OfyqAIm3mY3kl/5uEAOHc3HFNLqRNYD 40 | bOxMygMCggEAEFeSxKz+xoITF/VrHtavsimcxk4IfZaAvQ7ZePkhUpbi4LrS0uFy 41 | N2ZkSgT+ubk1HOOfrwnqdHe3Uf1LuOudlWISLq/8Hw9k0BEoOhOOCu0OQA/UdSBy 42 | rPv0Cux7TWY8dtV5RIoZyUmHUD95M9tLIpkpsKXUHWVLXpyn/ut33XwWKTDQ7dBp 43 | gjBVGJYG/gaUmW3UjLm1i8H3OmVrjbrirft4yOr4A+GmmErq/4qW7DXK7tVe1050 44 | g7iW4jqc6gi5ifWD/fwGqzYsatxJV4mRT3uIpJDnUewFav2a/jfLUsC1ldG88spM 45 | g3LgDe5XzXKU7BmPi9ziFYToBcIAlvlEOQKCAQEAym2ZqlsbnuV+e+MhBuFLuyfc 46 | R+XGZdbHa/rLMZHmRmNCDzGrSkgaoN7iCU9h/yK0NzWpq7gv/YHKz919RexZyLj9 47 | gSTuNOCb4zI4yTyb9kIiYKdHptVPxZzZ7RfA6/+ArNxJbBxxSoLc/U1FN+RT3g3V 48 | F6XwilzWXi2iDEiw3X73JvwSl1sbJopEnuw4AS/pwFlHGedjOKO/TS9izGCyaNVP 49 | 8s3bDGBYeHiBh5VzOJik+u04snOHVgq087XjWsQ/VJ2dmDWqSw8hV+qybkbqoE++ 50 | yoe3wRT7hC9K8XV+sZiAMpCak2LzEbdPDKBOV/H/XP7gb2hIhALPOyVjheyA0Q== 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /spec/fixtures/idp.key.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0Yt2XvyXN1uRtxjqG3re 3 | I8v8x+QIdIc7FaLOROZ/nyaxVsHZ4kiSJ0aZ29yNRrk0IkUfJiLBChiH5STpBv/N 4 | /K4XVudUt3Ff8u+grrgxMM/DhE0ElSpCzBPl+jePONPakQO7fG8xTjiHPJK5Rxqz 5 | Jog6dAv4foHp8ps38XQlPCbbocoHoDF+sgiFkmCeIpKmXCH2SRWCMo+9Em8r4Tzp 6 | zD1+wadpwYtljIuF6Hpm0yy4hW0/9upMHlzQAmrAM7R+L30SF+x+3Ex6Y6qusmHM 7 | Rq7oXJns/3xsgv6KihWroS8eSaQoGrddE8Ez6DhBmff93qcsxgnPGs2moM40XqfZ 8 | n7uZ1YWQLYQdnEjLqngaKbqRlg1Z1wp9HBh9BohsCroLoWssC6rGsJM96brsiCFl 9 | WPJ49fImmJY9r4RTGX/HO7+Bpfv/UXTcQFmZjWFFYHFjqYQZ/bZROAxlL+OqDTxA 10 | spu9desJiYgIZQodvjmVJCQ8aHUgS2lvszq1pIeo0KR/+PRISsvXWDFupSirW+qD 11 | VKKtbDAYje+VuRq3Li7p4y2Ut9Ozf+2SMI7E86NsQqRK5ixCtq845wHEUsClzxVb 12 | eCa2vil2x0/D9us0qR8TulJ7gCpoRednt48rG9zDU6naRrEw2qByrnhTqV7XtDoF 13 | c/Kaxz1xYvIAelc2DctTAlsCAwEAAQ== 14 | -----END PUBLIC KEY----- 15 | -------------------------------------------------------------------------------- /spec/js/scope-element.spec.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | import '../../public/assets/js/scope-element.js'; 4 | 5 | describe('ScopeElement', () => { 6 | beforeEach(() => { 7 | document.body.innerHTML = ` 8 | 11 | 15 | `; 16 | }); 17 | 18 | describe('if the value is in default scope for selected ial value', () => { 19 | beforeEach(() => { 20 | document.body.innerHTML += ` 21 | 22 | 23 | 24 | `; 25 | }); 26 | 27 | it('is checked when connected', () => { 28 | const checkbox = document.querySelector('[type=checkbox]'); 29 | assert(checkbox.checked); 30 | }); 31 | }); 32 | 33 | describe('if value is not in default scope for selected ial value', () => { 34 | beforeEach(() => { 35 | document.body.innerHTML += ` 36 | 37 | 38 | 39 | `; 40 | }); 41 | 42 | it('is not checked when connected', () => { 43 | const checkbox = document.querySelector('[type=checkbox]'); 44 | assert(!checkbox.checked); 45 | }); 46 | }); 47 | 48 | describe('selected ial value changes', () => { 49 | describe('if in default scope', () => { 50 | beforeEach(() => { 51 | document.body.innerHTML += ` 52 | 53 | 54 | 55 | `; 56 | 57 | const select = document.querySelector('select'); 58 | select.value = '2'; 59 | select.dispatchEvent(new window.InputEvent('input')); 60 | }); 61 | 62 | it('is checked', () => { 63 | const checkbox = document.querySelector('[type=checkbox]'); 64 | assert(checkbox.checked); 65 | }); 66 | }); 67 | 68 | describe('if not in default scope', () => { 69 | beforeEach(() => { 70 | document.body.innerHTML += ` 71 | 72 | 73 | 74 | `; 75 | 76 | const select = document.querySelector('select'); 77 | select.value = '2'; 78 | select.dispatchEvent(new window.InputEvent('input')); 79 | }); 80 | 81 | it('is unchecked', () => { 82 | const checkbox = document.querySelector('[type=checkbox]'); 83 | assert(!checkbox.checked); 84 | }); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /spec/openid_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe LoginGov::OidcSinatra::OpenidConfiguration do 4 | let(:host) { 'http://localhost:3000' } 5 | let(:authorization_endpoint) { "#{host}/openid/authorize" } 6 | let(:token_endpoint) { "#{host}/api/openid/token" } 7 | let(:jwks_uri) { "#{host}/api/openid/certs" } 8 | let(:end_session_endpoint) { "#{host}/openid/logout" } 9 | let(:client_id) { 'urn:gov:gsa:openidconnect:sp:sinatra' } 10 | 11 | let(:configuration_uri) { "#{host}/.well-known/openid-configuration" } 12 | 13 | before do 14 | stub_request(:get, "#{host}/.well-known/openid-configuration"). 15 | to_return(body: { 16 | authorization_endpoint: authorization_endpoint, 17 | token_endpoint: token_endpoint, 18 | jwks_uri: jwks_uri, 19 | end_session_endpoint: end_session_endpoint, 20 | }.to_json) 21 | end 22 | 23 | describe '#live' do 24 | it 'raises error if request fails' do 25 | stub_request(:get, "#{host}/.well-known/openid-configuration"). 26 | to_return(body: '', status: 401) 27 | 28 | expect { LoginGov::OidcSinatra::OpenidConfiguration.live }. 29 | to raise_error(LoginGov::OidcSinatra::AppError) 30 | end 31 | end 32 | 33 | describe '#cached' do 34 | it 'does not make more than one HTTP request' do 35 | oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached 36 | cached_oidc_config = LoginGov::OidcSinatra::OpenidConfiguration.cached 37 | expect(oidc_config).to eq cached_oidc_config 38 | expect(a_request(:get, configuration_uri)).to have_been_made.once 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rack/test' 2 | require 'rspec' 3 | require 'simplecov' 4 | require 'webmock/rspec' 5 | 6 | ENV['RACK_ENV'] = 'test' 7 | 8 | SimpleCov.start do 9 | add_filter '/spec' 10 | end 11 | 12 | require_relative '../app' 13 | 14 | module RSpecMixin 15 | include Rack::Test::Methods 16 | 17 | def app 18 | described_class 19 | end 20 | 21 | def read_fixture_file(file) 22 | File.read( 23 | File.join(File.expand_path(File.dirname(__FILE__)), 'fixtures', file), 24 | ) 25 | end 26 | end 27 | 28 | RSpec.configure do |config| 29 | config.include RSpecMixin 30 | config.disable_monkey_patching! 31 | end 32 | -------------------------------------------------------------------------------- /views/attempts.erb: -------------------------------------------------------------------------------- 1 | Skip to main content 2 |
3 | TEST Do not use real personal information (demo purposes only) TEST 4 |
5 |
6 | U.S. flag 7 | A DEMO website of the United States government 8 |
9 | 10 |
11 |
12 |

Attempt events

13 |
14 | 15 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | <% attempts_events.sort_by {|e| e['iat'] }.each do |attempt_event| %> 35 | <%== erb :event, locals: { 36 | event_data: attempt_event['events'], 37 | jwe_data: attempt_event.except('events'), 38 | } 39 | %> 40 | <% end %> 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /views/errors.erb: -------------------------------------------------------------------------------- 1 |
2 |

An Error has Occurred

3 |
4 | <%= error %> 5 |
6 |
7 | -------------------------------------------------------------------------------- /views/event.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 19 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /views/failure_to_proof.erb: -------------------------------------------------------------------------------- 1 |

Failure to Proof

2 |
We were unable to verify your identity.
3 | -------------------------------------------------------------------------------- /views/header.erb: -------------------------------------------------------------------------------- 1 | 158 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | Skip to main content 2 |
3 | TEST Do not use real personal information (demo purposes only) TEST 4 |
5 |
6 | U.S. flag 7 | A DEMO website of the United States government 8 |
9 |
10 | <%== erb :header, locals: { logout_msg:, login_msg:, user_email:, access_denied:, aal:, ial: } %> 11 |
12 |
13 |
14 |
15 | <% if user_email %> 16 |

17 | Welcome Back! 18 | You are logged in 19 |

20 |

When finished, use the previous 'Log out' button to sign out.

21 | <% else %> 22 |

23 | Let's get started! 24 | It's easy to make an account 25 |

26 |

Setting it up takes just a few, short steps.

27 | <% end %> 28 |
29 |
30 | <% if userinfo %> 31 |
32 |
33 | Received user info:
34 |
35 |
    36 | <% userinfo.each_pair do |key, val| %> 37 | <% val = maybe_redact_ssn(val) if key.to_s == 'social_security_number' %> 38 |
  • 39 |
    <%= key.inspect %>: <%= val.inspect %>
    40 |
  • 41 | <% end %> 42 |
43 |
44 |
45 |
46 | <% end %> 47 |
48 |
49 |
50 |
51 |

We are with you every step of the way

52 |
53 |
54 |

We hope your visit will help you understand the opportunities and potential rewards that are available to you. We created this website to help you gain a better understanding of these concepts. Most importantly, we hope you see the value of working with us to pursue your goals. We're here to help educate you about the basic concepts, to help you learn more about who we are, and to give you fast, easy access to opportunities.

55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |

Create a plan for success

64 |

We offer educational articles that outline key concepts and highlight services designed to fill the gaps in your planning.

65 |
66 |
67 |
68 |
69 |

Evaluate your current and future needs

70 |

On our website, you'll find valuable information for evaluating your current situation and progress toward your goals.

71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |

Come back often

79 |

We hope you take advantage of this resource and visit us often. Be sure to add our site to your list of "favorites" in your Internet browser. We frequently update our information, and we wouldn't want you to miss any developments.

80 | New developments 81 |
82 |
83 |
84 | 158 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Identity 8 | 9 | 10 | 11 | 12 | 13 | <%== yield %> 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@uswds/uswds@3.8.2": 6 | version "3.8.2" 7 | resolved "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.2.tgz" 8 | integrity sha512-8sTx/GqlbTwSIK+0AFOGrYdaW1rKVB7Bp0+v9AMVt3I5vPK7CL0+I6vlclSf3U7ysJZeTTdkNS8q89sIAeL+AA== 9 | dependencies: 10 | object-assign "4.1.1" 11 | receptor "1.0.0" 12 | resolve-id-refs "0.1.0" 13 | 14 | agent-base@^7.0.2, agent-base@^7.1.0: 15 | version "7.1.1" 16 | resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz" 17 | integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== 18 | dependencies: 19 | debug "^4.3.4" 20 | 21 | asynckit@^0.4.0: 22 | version "0.4.0" 23 | resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" 24 | integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== 25 | 26 | combined-stream@^1.0.8: 27 | version "1.0.8" 28 | resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" 29 | integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== 30 | dependencies: 31 | delayed-stream "~1.0.0" 32 | 33 | cssstyle@^4.1.0: 34 | version "4.1.0" 35 | resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz" 36 | integrity sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA== 37 | dependencies: 38 | rrweb-cssom "^0.7.1" 39 | 40 | data-urls@^5.0.0: 41 | version "5.0.0" 42 | resolved "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz" 43 | integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== 44 | dependencies: 45 | whatwg-mimetype "^4.0.0" 46 | whatwg-url "^14.0.0" 47 | 48 | debug@4, debug@^4.3.4: 49 | version "4.3.6" 50 | resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" 51 | integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== 52 | dependencies: 53 | ms "2.1.2" 54 | 55 | decimal.js@^10.4.3: 56 | version "10.4.3" 57 | resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz" 58 | integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== 59 | 60 | delayed-stream@~1.0.0: 61 | version "1.0.0" 62 | resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" 63 | integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== 64 | 65 | element-closest@^2.0.1: 66 | version "2.0.2" 67 | resolved "https://registry.npmjs.org/element-closest/-/element-closest-2.0.2.tgz" 68 | integrity sha1-cqdAoQdFM4LijfnOXbtajfD5Zuw= 69 | 70 | entities@^4.4.0: 71 | version "4.5.0" 72 | resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" 73 | integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== 74 | 75 | form-data@^4.0.0: 76 | version "4.0.0" 77 | resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz" 78 | integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== 79 | dependencies: 80 | asynckit "^0.4.0" 81 | combined-stream "^1.0.8" 82 | mime-types "^2.1.12" 83 | 84 | html-encoding-sniffer@^4.0.0: 85 | version "4.0.0" 86 | resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz" 87 | integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== 88 | dependencies: 89 | whatwg-encoding "^3.1.1" 90 | 91 | http-proxy-agent@^7.0.2: 92 | version "7.0.2" 93 | resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz" 94 | integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== 95 | dependencies: 96 | agent-base "^7.1.0" 97 | debug "^4.3.4" 98 | 99 | https-proxy-agent@^7.0.5: 100 | version "7.0.5" 101 | resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz" 102 | integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== 103 | dependencies: 104 | agent-base "^7.0.2" 105 | debug "4" 106 | 107 | iconv-lite@0.6.3: 108 | version "0.6.3" 109 | resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" 110 | integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== 111 | dependencies: 112 | safer-buffer ">= 2.1.2 < 3.0.0" 113 | 114 | is-potential-custom-element-name@^1.0.1: 115 | version "1.0.1" 116 | resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz" 117 | integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== 118 | 119 | jsdom-global@^3.0.2: 120 | version "3.0.2" 121 | resolved "https://registry.npmjs.org/jsdom-global/-/jsdom-global-3.0.2.tgz" 122 | integrity sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg== 123 | 124 | jsdom@^25.0.1: 125 | version "25.0.1" 126 | resolved "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz" 127 | integrity sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw== 128 | dependencies: 129 | cssstyle "^4.1.0" 130 | data-urls "^5.0.0" 131 | decimal.js "^10.4.3" 132 | form-data "^4.0.0" 133 | html-encoding-sniffer "^4.0.0" 134 | http-proxy-agent "^7.0.2" 135 | https-proxy-agent "^7.0.5" 136 | is-potential-custom-element-name "^1.0.1" 137 | nwsapi "^2.2.12" 138 | parse5 "^7.1.2" 139 | rrweb-cssom "^0.7.1" 140 | saxes "^6.0.0" 141 | symbol-tree "^3.2.4" 142 | tough-cookie "^5.0.0" 143 | w3c-xmlserializer "^5.0.0" 144 | webidl-conversions "^7.0.0" 145 | whatwg-encoding "^3.1.1" 146 | whatwg-mimetype "^4.0.0" 147 | whatwg-url "^14.0.0" 148 | ws "^8.18.0" 149 | xml-name-validator "^5.0.0" 150 | 151 | keyboardevent-key-polyfill@^1.0.2: 152 | version "1.1.0" 153 | resolved "https://registry.npmjs.org/keyboardevent-key-polyfill/-/keyboardevent-key-polyfill-1.1.0.tgz" 154 | integrity sha1-ijGdjkWhMXL8pWKGNy+QwdTHAUw= 155 | 156 | matches-selector@^1.0.0: 157 | version "1.2.0" 158 | resolved "https://registry.npmjs.org/matches-selector/-/matches-selector-1.2.0.tgz" 159 | integrity sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA== 160 | 161 | mime-db@1.52.0: 162 | version "1.52.0" 163 | resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" 164 | integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== 165 | 166 | mime-types@^2.1.12: 167 | version "2.1.35" 168 | resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" 169 | integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== 170 | dependencies: 171 | mime-db "1.52.0" 172 | 173 | ms@2.1.2: 174 | version "2.1.2" 175 | resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" 176 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 177 | 178 | nwsapi@^2.2.12: 179 | version "2.2.12" 180 | resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz" 181 | integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w== 182 | 183 | object-assign@4.1.1, object-assign@^4.1.0: 184 | version "4.1.1" 185 | resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" 186 | integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== 187 | 188 | parse5@^7.1.2: 189 | version "7.1.2" 190 | resolved "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz" 191 | integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== 192 | dependencies: 193 | entities "^4.4.0" 194 | 195 | punycode@^2.3.1: 196 | version "2.3.1" 197 | resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" 198 | integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== 199 | 200 | receptor@1.0.0: 201 | version "1.0.0" 202 | resolved "https://registry.npmjs.org/receptor/-/receptor-1.0.0.tgz" 203 | integrity sha512-yvVEqVQDNzEmGkluCkEdbKSXqZb3WGxotI/VukXIQ+4/BXEeXVjWtmC6jWaR1BIsmEAGYQy3OTaNgDj2Svr01w== 204 | dependencies: 205 | element-closest "^2.0.1" 206 | keyboardevent-key-polyfill "^1.0.2" 207 | matches-selector "^1.0.0" 208 | object-assign "^4.1.0" 209 | 210 | resolve-id-refs@0.1.0: 211 | version "0.1.0" 212 | resolved "https://registry.npmjs.org/resolve-id-refs/-/resolve-id-refs-0.1.0.tgz" 213 | integrity sha512-hNS03NEmVpJheF7yfyagNh57XuKc0z+NkSO0oBbeO67o6IJKoqlDfnNIxhjp7aTWwjmSWZQhtiGrOgZXVyM90w== 214 | 215 | rrweb-cssom@^0.7.1: 216 | version "0.7.1" 217 | resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz" 218 | integrity sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg== 219 | 220 | "safer-buffer@>= 2.1.2 < 3.0.0": 221 | version "2.1.2" 222 | resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" 223 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 224 | 225 | saxes@^6.0.0: 226 | version "6.0.0" 227 | resolved "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz" 228 | integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== 229 | dependencies: 230 | xmlchars "^2.2.0" 231 | 232 | symbol-tree@^3.2.4: 233 | version "3.2.4" 234 | resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz" 235 | integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== 236 | 237 | tldts-core@^6.1.65: 238 | version "6.1.65" 239 | resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.65.tgz" 240 | integrity sha512-Uq5t0N0Oj4nQSbU8wFN1YYENvMthvwU13MQrMJRspYCGLSAZjAfoBOJki5IQpnBM/WFskxxC/gIOTwaedmHaSg== 241 | 242 | tldts@^6.1.32: 243 | version "6.1.65" 244 | resolved "https://registry.npmjs.org/tldts/-/tldts-6.1.65.tgz" 245 | integrity sha512-xU9gLTfAGsADQ2PcWee6Hg8RFAv0DnjMGVJmDnUmI8a9+nYmapMQix4afwrdaCtT+AqP4MaxEzu7cCrYmBPbzQ== 246 | dependencies: 247 | tldts-core "^6.1.65" 248 | 249 | tough-cookie@^5.0.0: 250 | version "5.0.0" 251 | resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz" 252 | integrity sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q== 253 | dependencies: 254 | tldts "^6.1.32" 255 | 256 | tr46@^5.0.0: 257 | version "5.0.0" 258 | resolved "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz" 259 | integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== 260 | dependencies: 261 | punycode "^2.3.1" 262 | 263 | w3c-xmlserializer@^5.0.0: 264 | version "5.0.0" 265 | resolved "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz" 266 | integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== 267 | dependencies: 268 | xml-name-validator "^5.0.0" 269 | 270 | webidl-conversions@^7.0.0: 271 | version "7.0.0" 272 | resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz" 273 | integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== 274 | 275 | whatwg-encoding@^3.1.1: 276 | version "3.1.1" 277 | resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz" 278 | integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== 279 | dependencies: 280 | iconv-lite "0.6.3" 281 | 282 | whatwg-mimetype@^4.0.0: 283 | version "4.0.0" 284 | resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz" 285 | integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== 286 | 287 | whatwg-url@^14.0.0: 288 | version "14.0.0" 289 | resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz" 290 | integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== 291 | dependencies: 292 | tr46 "^5.0.0" 293 | webidl-conversions "^7.0.0" 294 | 295 | ws@^8.18.0: 296 | version "8.18.0" 297 | resolved "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz" 298 | integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== 299 | 300 | xml-name-validator@^5.0.0: 301 | version "5.0.0" 302 | resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz" 303 | integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== 304 | 305 | xmlchars@^2.2.0: 306 | version "2.2.0" 307 | resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz" 308 | integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== 309 | --------------------------------------------------------------------------------
TypeJTIIssued AtEvent data
4 | <%= event_data.keys.first.split('/').last %> 5 | 7 | <%= jwe_data['jti']%> 8 |
9 | 10 | 17 |
18 |
20 | <%= Time.at(jwe_data['iat']) %> 21 | 23 | <%= JSON.pretty_generate(event_data.values) %> 24 |