├── .browserslistrc ├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .kamal └── .keep ├── .rubocop.yml ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ └── webauthn-ruby.png │ └── stylesheets │ │ └── application.scss ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── credentials_controller.rb │ ├── home_controller.rb │ ├── registrations_controller.rb │ └── sessions_controller.rb ├── helpers │ └── application_helper.rb ├── javascript │ ├── application.js │ ├── controllers │ │ ├── add_credential_controller.js │ │ ├── application.js │ │ ├── feature_detection_controller.js │ │ ├── index.js │ │ ├── menu_controller.js │ │ ├── new_registration_controller.js │ │ ├── new_session_controller.js │ │ └── textfield_controller.js │ ├── credential.js │ ├── messenger.js │ └── stub_credentials.js ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ ├── credential.rb │ └── user.rb └── views │ ├── application │ ├── _feature_detection.html.erb │ ├── _github_ribbon.html.erb │ └── _rollbar_js.html.erb │ ├── home │ └── index.html.erb │ ├── layouts │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── registrations │ └── new.html.erb │ └── sessions │ └── new.html.erb ├── bin ├── bundle ├── docker-entrypoint ├── importmap ├── rails ├── rake ├── setup └── update ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── deploy.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── importmap.rb ├── initializers │ ├── assets.rb │ ├── content_security_policy.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── permissions_policy.rb │ ├── rollbar.rb │ ├── webauthn.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb └── spring.rb ├── db ├── migrate │ ├── 20180528192606_create_users.rb │ ├── 20180529223646_add_public_key_to_users.rb │ ├── 20180612192143_create_credentials.rb │ ├── 20180612192702_remove_credentials_from_users.rb │ ├── 20180704215403_add_nickname_to_credentials.rb │ ├── 20181005235558_rename_email_to_username.rb │ ├── 20190829002430_add_sign_count_to_credentials.rb │ ├── 20191002172648_add_webauthn_id_to_users.rb │ ├── 20191008202418_remove_current_challenge_from_users.rb │ └── 20200411210422_add_unique_index_to_credentials_external_id.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep └── tasks │ ├── .keep │ └── default.rake ├── log └── .keep ├── public ├── 400.html ├── 404.html ├── 406-unsupported-browser.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── controllers │ ├── .keep │ ├── registrations_controller_test.rb │ └── sessions_controller_test.rb ├── fixtures │ ├── .keep │ └── files │ │ └── .keep ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── system │ ├── .keep │ ├── add_credential_test.rb │ ├── registration_test.rb │ └── sign_in_test.rb └── test_helper.rb ├── tmp └── .keep └── vendor ├── .keep └── javascript └── .keep /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | /.gitignore 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files (except templates). 11 | /.env* 12 | !/.env*.erb 13 | 14 | # Ignore all default key files. 15 | /config/master.key 16 | /config/credentials/*.key 17 | 18 | # Ignore all logfiles and tempfiles. 19 | /log/* 20 | /tmp/* 21 | !/log/.keep 22 | !/tmp/.keep 23 | 24 | # Ignore pidfiles, but keep the directory. 25 | /tmp/pids/* 26 | !/tmp/pids/.keep 27 | 28 | # Ignore storage (uploaded files in development and any SQLite databases). 29 | /storage/* 30 | !/storage/.keep 31 | /tmp/storage/* 32 | !/tmp/storage/.keep 33 | 34 | # Ignore assets. 35 | /node_modules/ 36 | /app/assets/builds/* 37 | !/app/assets/builds/.keep 38 | /public/assets 39 | 40 | # Ignore development files 41 | /.devcontainer 42 | 43 | # Ignore Docker-related files 44 | /.dockerignore 45 | /Dockerfile* 46 | 47 | /.kamal/secrets* 48 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | config/credentials.yml.enc diff=rails_credentials 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | all-gems: 9 | patterns: 10 | - "*" 11 | exclude-patterns: 12 | - "rails" 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out repository code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true 21 | 22 | - name: Run tests 23 | env: 24 | RAILS_ENV: test 25 | run: | 26 | bundle exec rake db:create db:schema:load 27 | bundle exec rake 28 | TEST_BROWSER=headless_chrome bundle exec rake test:system 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | concurrency: 4 | group: ${{ github.workflow }} 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | inputs: 12 | commit: 13 | description: "Commit (SHA, tag, branch, etc)" 14 | required: true 15 | default: "master" 16 | 17 | jobs: 18 | deploy: 19 | if: github.repository == 'cedarcode/webauthn-rails-demo-app' 20 | 21 | runs-on: ubuntu-latest 22 | 23 | env: 24 | BUNDLE_ONLY: 'deploy' 25 | 26 | steps: 27 | - name: Check out repository code 28 | uses: actions/checkout@v4 29 | with: 30 | ref: ${{ inputs.commit }} 31 | 32 | - name: Start the ssh-agent 33 | uses: webfactory/ssh-agent@v0.9.1 34 | with: 35 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 36 | 37 | - name: Configure AWS credentials 38 | uses: aws-actions/configure-aws-credentials@v4 39 | with: 40 | aws-access-key-id : ${{ secrets.AWS_ACCESS_KEY_ID }} 41 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 42 | aws-region : us-east-1 43 | 44 | - name: Set up Ruby 45 | uses: ruby/setup-ruby@v1 46 | with: 47 | bundler-cache: true 48 | 49 | - name: Set up Docker Buildx for cache 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: Expose GitHub Runtime for cache 53 | uses: crazy-max/ghaction-github-runtime@v3 54 | 55 | - name: Generate kamal secrets 56 | run: | 57 | cat <> .kamal/secrets 58 | RAILS_MASTER_KEY=${{secrets.RAILS_MASTER_KEY}} 59 | EOT 60 | 61 | - name: Run deploy command 62 | run: bundle exec kamal deploy 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-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 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore storage (uploaded files in development and any SQLite databases). 17 | /storage/* 18 | !/storage/.keep 19 | 20 | /public/assets 21 | .byebug_history 22 | 23 | # Ignore master key for decrypting credentials and more. 24 | /config/master.key 25 | 26 | /.env 27 | 28 | /public/packs 29 | /public/packs-test 30 | 31 | /.kamal/secrets* 32 | -------------------------------------------------------------------------------- /.kamal/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/.kamal/.keep -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: rubocop-rails 2 | 3 | inherit_mode: 4 | merge: 5 | - Exclude 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.2 9 | DisabledByDefault: true 10 | Exclude: 11 | - "bin/*" 12 | - "config/**/*.rb" 13 | - "db/schema.rb" 14 | - "tmp/**/*" 15 | 16 | Bundler: 17 | Enabled: true 18 | 19 | Gemspec: 20 | Enabled: true 21 | 22 | Gemspec/AddRuntimeDependency: 23 | Enabled: true 24 | 25 | Gemspec/DeprecatedAttributeAssignment: 26 | Enabled: true 27 | 28 | Gemspec/DevelopmentDependencies: 29 | Enabled: true 30 | 31 | Gemspec/RequireMFA: 32 | Enabled: true 33 | 34 | Layout: 35 | Enabled: true 36 | 37 | Layout/LineContinuationLeadingSpace: 38 | Enabled: true 39 | 40 | Layout/LineContinuationSpacing: 41 | Enabled: true 42 | 43 | Layout/LineEndStringConcatenationIndentation: 44 | Enabled: true 45 | 46 | Layout/LineLength: 47 | Max: 120 48 | 49 | Layout/SpaceBeforeBrackets: 50 | Enabled: true 51 | 52 | Lint: 53 | Enabled: true 54 | 55 | Lint/AmbiguousAssignment: 56 | Enabled: true 57 | 58 | Lint/AmbiguousOperatorPrecedence: 59 | Enabled: true 60 | 61 | Lint/AmbiguousRange: 62 | Enabled: true 63 | 64 | Lint/ConstantOverwrittenInRescue: 65 | Enabled: true 66 | 67 | Lint/DeprecatedConstants: 68 | Enabled: true 69 | 70 | Lint/DuplicateBranch: 71 | Enabled: true 72 | 73 | Lint/DuplicateMagicComment: 74 | Enabled: true 75 | 76 | Lint/DuplicateMatchPattern: 77 | Enabled: true 78 | 79 | Lint/DuplicateRegexpCharacterClassElement: 80 | Enabled: true 81 | 82 | Lint/EmptyBlock: 83 | Enabled: true 84 | 85 | Lint/EmptyClass: 86 | Enabled: true 87 | 88 | Lint/EmptyInPattern: 89 | Enabled: true 90 | 91 | Lint/IncompatibleIoSelectWithFiberScheduler: 92 | Enabled: true 93 | 94 | Lint/ItWithoutArgumentsInBlock: 95 | Enabled: true 96 | 97 | Lint/LambdaWithoutLiteralBlock: 98 | Enabled: true 99 | 100 | Lint/LiteralAssignmentInCondition: 101 | Enabled: true 102 | 103 | Lint/MixedCaseRange: 104 | Enabled: true 105 | 106 | Lint/NoReturnInBeginEndBlocks: 107 | Enabled: true 108 | 109 | Lint/NonAtomicFileOperation: 110 | Enabled: true 111 | 112 | Lint/NumberedParameterAssignment: 113 | Enabled: true 114 | 115 | Lint/OrAssignmentToConstant: 116 | Enabled: true 117 | 118 | Lint/RedundantDirGlobSort: 119 | Enabled: true 120 | 121 | Lint/RedundantRegexpQuantifiers: 122 | Enabled: true 123 | 124 | Lint/RefinementImportMethods: 125 | Enabled: true 126 | 127 | Lint/RequireRangeParentheses: 128 | Enabled: true 129 | 130 | Lint/RequireRelativeSelfPath: 131 | Enabled: true 132 | 133 | Lint/SymbolConversion: 134 | Enabled: true 135 | 136 | Lint/ToEnumArguments: 137 | Enabled: true 138 | 139 | Lint/TripleQuotes: 140 | Enabled: true 141 | 142 | Lint/UnexpectedBlockArity: 143 | Enabled: true 144 | 145 | Lint/UnmodifiedReduceAccumulator: 146 | Enabled: true 147 | 148 | Lint/UselessRescue: 149 | Enabled: true 150 | 151 | Lint/UselessRuby2Keywords: 152 | Enabled: true 153 | 154 | Naming: 155 | Enabled: true 156 | 157 | Naming/BlockForwarding: 158 | Enabled: true 159 | 160 | Rails: 161 | Enabled: true 162 | 163 | Rails/ActionControllerFlashBeforeRender: 164 | Enabled: true 165 | 166 | Rails/ActionControllerTestCase: 167 | Enabled: true 168 | 169 | Rails/ActionOrder: 170 | Enabled: true 171 | 172 | Rails/ActiveRecordCallbacksOrder: 173 | Enabled: true 174 | 175 | Rails/ActiveSupportOnLoad: 176 | Enabled: true 177 | 178 | Rails/AddColumnIndex: 179 | Enabled: true 180 | 181 | Rails/AfterCommitOverride: 182 | Enabled: true 183 | 184 | Rails/AttributeDefaultBlockValue: 185 | Enabled: true 186 | 187 | # to deal with https://github.com/rubocop-hq/rubocop/issues/6002 188 | Rails/BulkChangeTable: 189 | Enabled: false 190 | 191 | Rails/CompactBlank: 192 | Enabled: true 193 | 194 | Rails/DangerousColumnNames: 195 | Enabled: true 196 | 197 | Rails/DeprecatedActiveModelErrorsMethods: 198 | Enabled: true 199 | 200 | Rails/DotSeparatedKeys: 201 | Enabled: true 202 | 203 | Rails/DuplicateAssociation: 204 | Enabled: true 205 | 206 | Rails/DuplicateScope: 207 | Enabled: true 208 | 209 | Rails/DurationArithmetic: 210 | Enabled: true 211 | 212 | Rails/EagerEvaluationLogMessage: 213 | Enabled: true 214 | 215 | Rails/EnvLocal: 216 | Enabled: true 217 | 218 | Rails/ExpandedDateRange: 219 | Enabled: true 220 | 221 | Rails/FindById: 222 | Enabled: true 223 | 224 | Rails/FreezeTime: 225 | Enabled: true 226 | 227 | Rails/I18nLazyLookup: 228 | Enabled: true 229 | 230 | Rails/I18nLocaleAssignment: 231 | Enabled: true 232 | 233 | Rails/I18nLocaleTexts: 234 | Enabled: true 235 | 236 | Rails/IgnoredColumnsAssignment: 237 | Enabled: true 238 | 239 | Rails/Inquiry: 240 | Enabled: true 241 | 242 | Rails/MailerName: 243 | Enabled: true 244 | 245 | Rails/MatchRoute: 246 | Enabled: true 247 | 248 | Rails/MigrationClassName: 249 | Enabled: true 250 | 251 | Rails/NegateInclude: 252 | Enabled: true 253 | 254 | Rails/Pluck: 255 | Enabled: true 256 | 257 | Rails/PluckInWhere: 258 | Enabled: true 259 | 260 | Rails/RedundantActiveRecordAllMethod: 261 | Enabled: true 262 | 263 | Rails/RedundantPresenceValidationOnBelongsTo: 264 | Enabled: true 265 | 266 | Rails/RedundantTravelBack: 267 | Enabled: true 268 | 269 | Rails/RenderInline: 270 | Enabled: true 271 | 272 | Rails/RenderPlainText: 273 | Enabled: true 274 | 275 | Rails/ResponseParsedBody: 276 | Enabled: true 277 | 278 | Rails/RootJoinChain: 279 | Enabled: true 280 | 281 | Rails/RootPathnameMethods: 282 | Enabled: true 283 | 284 | Rails/RootPublicPath: 285 | Enabled: true 286 | 287 | Rails/SelectMap: 288 | Enabled: true 289 | 290 | Rails/ShortI18n: 291 | Enabled: true 292 | 293 | Rails/SquishedSQLHeredocs: 294 | Enabled: true 295 | 296 | Rails/StripHeredoc: 297 | Enabled: true 298 | 299 | Rails/ThreeStateBooleanColumn: 300 | Enabled: true 301 | 302 | Rails/TimeZoneAssignment: 303 | Enabled: true 304 | 305 | Rails/ToFormattedS: 306 | Enabled: true 307 | 308 | Rails/ToSWithArgument: 309 | Enabled: true 310 | 311 | Rails/TopLevelHashWithIndifferentAccess: 312 | Enabled: true 313 | 314 | Rails/TransactionExitStatement: 315 | Enabled: true 316 | 317 | Rails/UnusedRenderContent: 318 | Enabled: true 319 | 320 | Rails/WhereEquals: 321 | Enabled: true 322 | 323 | Rails/WhereExists: 324 | Enabled: true 325 | 326 | Rails/WhereMissing: 327 | Enabled: true 328 | 329 | Rails/WhereNot: 330 | Enabled: true 331 | 332 | Rails/WhereNotWithMultipleConditions: 333 | Enabled: true 334 | 335 | Rails/WhereRange: 336 | Enabled: true 337 | 338 | Security: 339 | Enabled: true 340 | 341 | Style/BlockComments: 342 | Enabled: true 343 | 344 | Style/CaseEquality: 345 | Enabled: true 346 | 347 | Style/ClassMethods: 348 | Enabled: true 349 | 350 | Style/ClassVars: 351 | Enabled: true 352 | 353 | Style/CommentAnnotation: 354 | Enabled: true 355 | 356 | Security/CompoundHash: 357 | Enabled: true 358 | 359 | Style/ConditionalAssignment: 360 | Enabled: true 361 | 362 | Style/DefWithParentheses: 363 | Enabled: true 364 | 365 | Style/Dir: 366 | Enabled: true 367 | 368 | Style/EachForSimpleLoop: 369 | Enabled: true 370 | 371 | Style/EachWithObject: 372 | Enabled: true 373 | 374 | Style/EmptyBlockParameter: 375 | Enabled: true 376 | 377 | Style/EmptyCaseCondition: 378 | Enabled: true 379 | 380 | Style/EmptyElse: 381 | Enabled: true 382 | 383 | Style/EmptyLambdaParameter: 384 | Enabled: true 385 | 386 | Style/EmptyLiteral: 387 | Enabled: true 388 | 389 | Style/EvenOdd: 390 | Enabled: true 391 | 392 | Style/ExpandPathArguments: 393 | Enabled: true 394 | 395 | Style/For: 396 | Enabled: true 397 | 398 | Style/FrozenStringLiteralComment: 399 | Enabled: true 400 | 401 | Style/GlobalVars: 402 | Enabled: true 403 | 404 | Style/HashSyntax: 405 | Enabled: true 406 | 407 | Style/IdenticalConditionalBranches: 408 | Enabled: true 409 | 410 | Style/IfInsideElse: 411 | Enabled: true 412 | 413 | Style/InverseMethods: 414 | Enabled: true 415 | 416 | Security/IoMethods: 417 | Enabled: true 418 | 419 | Style/MethodCallWithoutArgsParentheses: 420 | Enabled: true 421 | 422 | Style/MethodDefParentheses: 423 | Enabled: true 424 | 425 | Style/MultilineMemoization: 426 | Enabled: true 427 | 428 | Style/MutableConstant: 429 | Enabled: true 430 | 431 | Style/NestedParenthesizedCalls: 432 | Enabled: true 433 | 434 | Style/OptionalArguments: 435 | Enabled: true 436 | 437 | Style/ParenthesesAroundCondition: 438 | Enabled: true 439 | 440 | Style/RedundantBegin: 441 | Enabled: true 442 | 443 | Style/RedundantConditional: 444 | Enabled: true 445 | 446 | Style/RedundantException: 447 | Enabled: true 448 | 449 | Style/RedundantFreeze: 450 | Enabled: true 451 | 452 | Style/RedundantInterpolation: 453 | Enabled: true 454 | 455 | Style/RedundantParentheses: 456 | Enabled: true 457 | 458 | Style/RedundantPercentQ: 459 | Enabled: true 460 | 461 | Style/RedundantReturn: 462 | Enabled: true 463 | 464 | Style/RedundantSelf: 465 | Enabled: true 466 | 467 | Style/Semicolon: 468 | Enabled: true 469 | 470 | Style/SingleLineMethods: 471 | Enabled: true 472 | 473 | Style/SpecialGlobalVars: 474 | Enabled: true 475 | 476 | Style/SymbolLiteral: 477 | Enabled: true 478 | 479 | Style/TrailingBodyOnClass: 480 | Enabled: true 481 | 482 | Style/TrailingBodyOnMethodDefinition: 483 | Enabled: true 484 | 485 | Style/TrailingBodyOnModule: 486 | Enabled: true 487 | 488 | Style/TrailingMethodEndStatement: 489 | Enabled: true 490 | 491 | Style/TrivialAccessors: 492 | Enabled: true 493 | 494 | Style/UnpackFirst: 495 | Enabled: true 496 | 497 | Style/YodaCondition: 498 | Enabled: true 499 | 500 | Style/ZeroLengthPredicate: 501 | Enabled: true 502 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 4 | ARG RUBY_VERSION=3.4.2 5 | FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base 6 | 7 | # Rails app lives here 8 | WORKDIR /rails 9 | 10 | # Install base packages 11 | RUN apt-get update -qq && \ 12 | apt-get install --no-install-recommends -y curl libjemalloc2 13 | 14 | # Set production environment 15 | ENV RAILS_ENV="production" \ 16 | BUNDLE_DEPLOYMENT="1" \ 17 | BUNDLE_PATH="/usr/local/bundle" \ 18 | BUNDLE_WITHOUT="development" 19 | 20 | # Throw-away build stage to reduce size of final image 21 | FROM base as build 22 | 23 | # Install packages needed to build gems 24 | RUN apt-get update -qq && \ 25 | apt-get install --no-install-recommends -y build-essential git libssl-dev libyaml-dev pkg-config 26 | 27 | # Install application gems 28 | COPY .ruby-version Gemfile Gemfile.lock ./ 29 | RUN bundle install && \ 30 | rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ 31 | bundle exec bootsnap precompile --gemfile 32 | 33 | # Copy application code 34 | COPY . . 35 | 36 | # Precompile bootsnap code for faster boot times 37 | RUN bundle exec bootsnap precompile app/ lib/ 38 | 39 | # Precompiling assets for production without requiring secret RAILS_MASTER_KEY 40 | RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile 41 | 42 | # Final stage for app image 43 | FROM base 44 | 45 | # Clean up installation packages to reduce image size 46 | RUN rm -rf /var/lib/apt/lists /var/cache/apt/archives 47 | 48 | # Copy built artifacts: gems, application 49 | COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" 50 | COPY --from=build /rails /rails 51 | 52 | # Run and own only the runtime files as a non-root user for security 53 | RUN groupadd --system --gid 1000 rails && \ 54 | useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 55 | chown -R rails:rails db log tmp storage 56 | USER 1000:1000 57 | 58 | # Entrypoint prepares the database. 59 | ENTRYPOINT ["/rails/bin/docker-entrypoint"] 60 | 61 | # Start the server by default, this can be overwritten at runtime 62 | EXPOSE 3000 63 | CMD ["./bin/rails", "server"] 64 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rails', "~> 8.0.1" 6 | gem "webauthn", "~> 3.4" 7 | 8 | gem 'bootsnap', '~> 1.18', require: false 9 | gem 'importmap-rails', '~> 2.1' 10 | gem 'puma', '~> 6.6' 11 | gem "rollbar", "~> 3.6" 12 | gem 'sassc-rails', '~> 2.0' 13 | gem 'sqlite3', '>= 1.4' 14 | gem 'stimulus-rails', '~> 1.3' 15 | 16 | group :development, :deploy do 17 | gem "kamal", '~> 2.6' 18 | end 19 | 20 | group :development, :test do 21 | gem 'byebug', '~> 12.0', platforms: [:mri, :mingw, :x64_mingw] 22 | gem 'rubocop', '~> 1.76', require: false 23 | gem 'rubocop-rails', '~> 2.32', require: false 24 | end 25 | 26 | group :development do 27 | gem "brakeman", '~> 7.0' 28 | gem "bundler-audit", '~> 0.9.1' 29 | gem "rack-mini-profiler", "~> 3.3" 30 | gem 'web-console', '~> 4.2', '>= 4.2.1' 31 | end 32 | 33 | group :test do 34 | gem 'capybara', '~> 3.26' 35 | gem 'minitest-stub_any_instance', '~> 1.0' 36 | gem 'selenium-webdriver', '~> 4.33' 37 | end 38 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (8.0.1) 5 | actionpack (= 8.0.1) 6 | activesupport (= 8.0.1) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | zeitwerk (~> 2.6) 10 | actionmailbox (8.0.1) 11 | actionpack (= 8.0.1) 12 | activejob (= 8.0.1) 13 | activerecord (= 8.0.1) 14 | activestorage (= 8.0.1) 15 | activesupport (= 8.0.1) 16 | mail (>= 2.8.0) 17 | actionmailer (8.0.1) 18 | actionpack (= 8.0.1) 19 | actionview (= 8.0.1) 20 | activejob (= 8.0.1) 21 | activesupport (= 8.0.1) 22 | mail (>= 2.8.0) 23 | rails-dom-testing (~> 2.2) 24 | actionpack (8.0.1) 25 | actionview (= 8.0.1) 26 | activesupport (= 8.0.1) 27 | nokogiri (>= 1.8.5) 28 | rack (>= 2.2.4) 29 | rack-session (>= 1.0.1) 30 | rack-test (>= 0.6.3) 31 | rails-dom-testing (~> 2.2) 32 | rails-html-sanitizer (~> 1.6) 33 | useragent (~> 0.16) 34 | actiontext (8.0.1) 35 | actionpack (= 8.0.1) 36 | activerecord (= 8.0.1) 37 | activestorage (= 8.0.1) 38 | activesupport (= 8.0.1) 39 | globalid (>= 0.6.0) 40 | nokogiri (>= 1.8.5) 41 | actionview (8.0.1) 42 | activesupport (= 8.0.1) 43 | builder (~> 3.1) 44 | erubi (~> 1.11) 45 | rails-dom-testing (~> 2.2) 46 | rails-html-sanitizer (~> 1.6) 47 | activejob (8.0.1) 48 | activesupport (= 8.0.1) 49 | globalid (>= 0.3.6) 50 | activemodel (8.0.1) 51 | activesupport (= 8.0.1) 52 | activerecord (8.0.1) 53 | activemodel (= 8.0.1) 54 | activesupport (= 8.0.1) 55 | timeout (>= 0.4.0) 56 | activestorage (8.0.1) 57 | actionpack (= 8.0.1) 58 | activejob (= 8.0.1) 59 | activerecord (= 8.0.1) 60 | activesupport (= 8.0.1) 61 | marcel (~> 1.0) 62 | activesupport (8.0.1) 63 | base64 64 | benchmark (>= 0.3) 65 | bigdecimal 66 | concurrent-ruby (~> 1.0, >= 1.3.1) 67 | connection_pool (>= 2.2.5) 68 | drb 69 | i18n (>= 1.6, < 2) 70 | logger (>= 1.4.2) 71 | minitest (>= 5.1) 72 | securerandom (>= 0.3) 73 | tzinfo (~> 2.0, >= 2.0.5) 74 | uri (>= 0.13.1) 75 | addressable (2.8.7) 76 | public_suffix (>= 2.0.2, < 7.0) 77 | android_key_attestation (0.3.0) 78 | ast (2.4.3) 79 | base64 (0.2.0) 80 | bcrypt_pbkdf (1.1.1) 81 | benchmark (0.4.0) 82 | bigdecimal (3.1.9) 83 | bindata (2.5.0) 84 | bindex (0.8.1) 85 | bootsnap (1.18.6) 86 | msgpack (~> 1.2) 87 | brakeman (7.0.2) 88 | racc 89 | builder (3.3.0) 90 | bundler-audit (0.9.2) 91 | bundler (>= 1.2.0, < 3) 92 | thor (~> 1.0) 93 | byebug (12.0.0) 94 | capybara (3.40.0) 95 | addressable 96 | matrix 97 | mini_mime (>= 0.1.3) 98 | nokogiri (~> 1.11) 99 | rack (>= 1.6.0) 100 | rack-test (>= 0.6.3) 101 | regexp_parser (>= 1.5, < 3.0) 102 | xpath (~> 3.2) 103 | cbor (0.5.9.8) 104 | concurrent-ruby (1.3.5) 105 | connection_pool (2.5.3) 106 | cose (1.3.1) 107 | cbor (~> 0.5.9) 108 | openssl-signature_algorithm (~> 1.0) 109 | crass (1.0.6) 110 | date (3.4.1) 111 | dotenv (3.1.8) 112 | drb (2.2.1) 113 | ed25519 (1.4.0) 114 | erubi (1.13.1) 115 | ffi (1.17.0) 116 | globalid (1.2.1) 117 | activesupport (>= 6.1) 118 | i18n (1.14.7) 119 | concurrent-ruby (~> 1.0) 120 | importmap-rails (2.1.0) 121 | actionpack (>= 6.0.0) 122 | activesupport (>= 6.0.0) 123 | railties (>= 6.0.0) 124 | io-console (0.8.0) 125 | irb (1.14.3) 126 | rdoc (>= 4.0.0) 127 | reline (>= 0.4.2) 128 | json (2.12.2) 129 | jwt (2.10.1) 130 | base64 131 | kamal (2.6.1) 132 | activesupport (>= 7.0) 133 | base64 (~> 0.2) 134 | bcrypt_pbkdf (~> 1.0) 135 | concurrent-ruby (~> 1.2) 136 | dotenv (~> 3.1) 137 | ed25519 (~> 1.4) 138 | net-ssh (~> 7.3) 139 | sshkit (>= 1.23.0, < 2.0) 140 | thor (~> 1.3) 141 | zeitwerk (>= 2.6.18, < 3.0) 142 | language_server-protocol (3.17.0.5) 143 | lint_roller (1.1.0) 144 | logger (1.7.0) 145 | loofah (2.23.1) 146 | crass (~> 1.0.2) 147 | nokogiri (>= 1.12.0) 148 | mail (2.8.1) 149 | mini_mime (>= 0.1.1) 150 | net-imap 151 | net-pop 152 | net-smtp 153 | marcel (1.0.4) 154 | matrix (0.4.2) 155 | mini_mime (1.1.5) 156 | mini_portile2 (2.8.8) 157 | minitest (5.25.5) 158 | minitest-stub_any_instance (1.0.3) 159 | msgpack (1.8.0) 160 | net-imap (0.5.6) 161 | date 162 | net-protocol 163 | net-pop (0.1.2) 164 | net-protocol 165 | net-protocol (0.2.2) 166 | timeout 167 | net-scp (4.1.0) 168 | net-ssh (>= 2.6.5, < 8.0.0) 169 | net-sftp (4.0.0) 170 | net-ssh (>= 5.0.0, < 8.0.0) 171 | net-smtp (0.5.1) 172 | net-protocol 173 | net-ssh (7.3.0) 174 | nio4r (2.7.4) 175 | nokogiri (1.17.2) 176 | mini_portile2 (~> 2.8.2) 177 | racc (~> 1.4) 178 | openssl (3.3.0) 179 | openssl-signature_algorithm (1.3.0) 180 | openssl (> 2.0) 181 | ostruct (0.6.1) 182 | parallel (1.27.0) 183 | parser (3.3.8.0) 184 | ast (~> 2.4.1) 185 | racc 186 | prism (1.4.0) 187 | psych (5.2.2) 188 | date 189 | stringio 190 | public_suffix (6.0.1) 191 | puma (6.6.0) 192 | nio4r (~> 2.0) 193 | racc (1.8.1) 194 | rack (3.1.15) 195 | rack-mini-profiler (3.3.1) 196 | rack (>= 1.2.0) 197 | rack-session (2.0.0) 198 | rack (>= 3.0.0) 199 | rack-test (2.1.0) 200 | rack (>= 1.3) 201 | rackup (2.2.1) 202 | rack (>= 3) 203 | rails (8.0.1) 204 | actioncable (= 8.0.1) 205 | actionmailbox (= 8.0.1) 206 | actionmailer (= 8.0.1) 207 | actionpack (= 8.0.1) 208 | actiontext (= 8.0.1) 209 | actionview (= 8.0.1) 210 | activejob (= 8.0.1) 211 | activemodel (= 8.0.1) 212 | activerecord (= 8.0.1) 213 | activestorage (= 8.0.1) 214 | activesupport (= 8.0.1) 215 | bundler (>= 1.15.0) 216 | railties (= 8.0.1) 217 | rails-dom-testing (2.2.0) 218 | activesupport (>= 5.0.0) 219 | minitest 220 | nokogiri (>= 1.6) 221 | rails-html-sanitizer (1.6.2) 222 | loofah (~> 2.21) 223 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 224 | railties (8.0.1) 225 | actionpack (= 8.0.1) 226 | activesupport (= 8.0.1) 227 | irb (~> 1.13) 228 | rackup (>= 1.0.0) 229 | rake (>= 12.2) 230 | thor (~> 1.0, >= 1.2.2) 231 | zeitwerk (~> 2.6) 232 | rainbow (3.1.1) 233 | rake (13.2.1) 234 | rdoc (6.10.0) 235 | psych (>= 4.0.0) 236 | regexp_parser (2.10.0) 237 | reline (0.6.0) 238 | io-console (~> 0.5) 239 | rexml (3.4.1) 240 | rollbar (3.6.2) 241 | rubocop (1.76.1) 242 | json (~> 2.3) 243 | language_server-protocol (~> 3.17.0.2) 244 | lint_roller (~> 1.1.0) 245 | parallel (~> 1.10) 246 | parser (>= 3.3.0.2) 247 | rainbow (>= 2.2.2, < 4.0) 248 | regexp_parser (>= 2.9.3, < 3.0) 249 | rubocop-ast (>= 1.45.0, < 2.0) 250 | ruby-progressbar (~> 1.7) 251 | unicode-display_width (>= 2.4.0, < 4.0) 252 | rubocop-ast (1.45.1) 253 | parser (>= 3.3.7.2) 254 | prism (~> 1.4) 255 | rubocop-rails (2.32.0) 256 | activesupport (>= 4.2.0) 257 | lint_roller (~> 1.1) 258 | rack (>= 1.1) 259 | rubocop (>= 1.75.0, < 2.0) 260 | rubocop-ast (>= 1.44.0, < 2.0) 261 | ruby-progressbar (1.13.0) 262 | rubyzip (2.4.1) 263 | safety_net_attestation (0.4.0) 264 | jwt (~> 2.0) 265 | sassc (2.4.0) 266 | ffi (~> 1.9) 267 | sassc-rails (2.1.2) 268 | railties (>= 4.0.0) 269 | sassc (>= 2.0) 270 | sprockets (> 3.0) 271 | sprockets-rails 272 | tilt 273 | securerandom (0.4.1) 274 | selenium-webdriver (4.33.0) 275 | base64 (~> 0.2) 276 | logger (~> 1.4) 277 | rexml (~> 3.2, >= 3.2.5) 278 | rubyzip (>= 1.2.2, < 3.0) 279 | websocket (~> 1.0) 280 | sprockets (4.2.1) 281 | concurrent-ruby (~> 1.0) 282 | rack (>= 2.2.4, < 4) 283 | sprockets-rails (3.5.2) 284 | actionpack (>= 6.1) 285 | activesupport (>= 6.1) 286 | sprockets (>= 3.0.0) 287 | sqlite3 (2.6.0) 288 | mini_portile2 (~> 2.8.0) 289 | sshkit (1.24.0) 290 | base64 291 | logger 292 | net-scp (>= 1.1.2) 293 | net-sftp (>= 2.1.2) 294 | net-ssh (>= 2.8.0) 295 | ostruct 296 | stimulus-rails (1.3.4) 297 | railties (>= 6.0.0) 298 | stringio (3.1.2) 299 | thor (1.3.2) 300 | tilt (2.4.0) 301 | timeout (0.4.3) 302 | tpm-key_attestation (0.14.0) 303 | bindata (~> 2.4) 304 | openssl (> 2.0) 305 | openssl-signature_algorithm (~> 1.0) 306 | tzinfo (2.0.6) 307 | concurrent-ruby (~> 1.0) 308 | unicode-display_width (3.1.4) 309 | unicode-emoji (~> 4.0, >= 4.0.4) 310 | unicode-emoji (4.0.4) 311 | uri (1.0.3) 312 | useragent (0.16.11) 313 | web-console (4.2.1) 314 | actionview (>= 6.0.0) 315 | activemodel (>= 6.0.0) 316 | bindex (>= 0.4.0) 317 | railties (>= 6.0.0) 318 | webauthn (3.4.0) 319 | android_key_attestation (~> 0.3.0) 320 | bindata (~> 2.4) 321 | cbor (~> 0.5.9) 322 | cose (~> 1.1) 323 | openssl (>= 2.2) 324 | safety_net_attestation (~> 0.4.0) 325 | tpm-key_attestation (~> 0.14.0) 326 | websocket (1.2.11) 327 | websocket-driver (0.7.7) 328 | base64 329 | websocket-extensions (>= 0.1.0) 330 | websocket-extensions (0.1.5) 331 | xpath (3.2.0) 332 | nokogiri (~> 1.8) 333 | zeitwerk (2.7.2) 334 | 335 | PLATFORMS 336 | ruby 337 | 338 | DEPENDENCIES 339 | bootsnap (~> 1.18) 340 | brakeman (~> 7.0) 341 | bundler-audit (~> 0.9.1) 342 | byebug (~> 12.0) 343 | capybara (~> 3.26) 344 | importmap-rails (~> 2.1) 345 | kamal (~> 2.6) 346 | minitest-stub_any_instance (~> 1.0) 347 | puma (~> 6.6) 348 | rack-mini-profiler (~> 3.3) 349 | rails (~> 8.0.1) 350 | rollbar (~> 3.6) 351 | rubocop (~> 1.76) 352 | rubocop-rails (~> 2.32) 353 | sassc-rails (~> 2.0) 354 | selenium-webdriver (~> 4.33) 355 | sqlite3 (>= 1.4) 356 | stimulus-rails (~> 1.3) 357 | web-console (~> 4.2, >= 4.2.1) 358 | webauthn (~> 3.4) 359 | 360 | BUNDLED WITH 361 | 2.5.20 362 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | release: bundle exec rake db:migrate 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebAuthn Rails Demo App 2 | 3 | Application demonstrating a [WebAuthn](https://en.wikipedia.org/wiki/WebAuthn) password-less login built with Ruby on Rails + [webauthn](https://github.com/cedarcode/webauthn-ruby) ruby gem. 4 | 5 | If you want to see an implementation of WebAuthn as a second factor authenticator in a Rails application, you can check it in [webauthn-2fa-rails-demo](https://github.com/cedarcode/webauthn-2fa-rails-demo). 6 | 7 | ## Want to try it? 8 | 9 | ### Option 1 — Visit the hosted version 10 | 11 | * Visit https://webauthn.cedarcode.com 12 | * Try logging in with 13 | * a username; 14 | * a [WebAuthn compatible authenticator](https://github.com/cedarcode/webauthn-ruby#prerequisites). 15 | 16 | 17 | ### Option 2 — Run it locally 18 | 19 | #### Prerequisites 20 | 21 | * Ruby 22 | 23 | #### Setup 24 | 25 | ``` 26 | $ git clone https://github.com/cedarcode/webauthn-rails-demo-app 27 | $ cd webauthn-rails-demo-app/ 28 | $ bundle install 29 | $ bundle exec rake db:setup 30 | ``` 31 | 32 | #### Running 33 | 34 | ``` 35 | $ bundle exec rails s 36 | ``` 37 | 38 | Now you can visit http://localhost:3000 to play with the demo site. 39 | 40 | ## Development 41 | 42 | ### Gem Update Policy 43 | 44 | #### Gemfile Version Constraints 45 | 46 | In `Gemfile` define gem dependencies using a version contrainst of `~> MAJOR.MINOR` by default (or ~> `0.MINOR.PATCH` if 47 | latest `MAJOR` is `0`), unless you have reasons to use something different. An example of an exception could be 48 | `rails`, which is known to make backwards-incompatible changes in minor level updates, so in that case we use 49 | `~> MAJOR.MINOR.PATCH`. 50 | 51 | #### Updating 52 | 53 | ``` 54 | $ gem install bundler-audit 55 | $ bundle audit --update 56 | $ bundle update --conservative --group test development 57 | $ bundle update --strict --patch 58 | $ bundle update --strict --minor 59 | $ bundle update --major 60 | $ bundle outdated --groups 61 | ``` 62 | 63 | More in: 64 | 65 | [Updating gems cheat sheet](https://medium.com/cedarcode/updating-gems-cheat-sheet-346d5666a181) 66 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../../javascript .js 4 | //= link_tree ../../../vendor/javascript .js 5 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/webauthn-ruby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/app/assets/images/webauthn-ruby.png -------------------------------------------------------------------------------- /app/assets/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | 18 | :root { 19 | --mdc-theme-primary: #212121; 20 | --mdc-theme-secondary: #f5f5f6; 21 | --mdc-theme-background: #e1e2e1; 22 | } 23 | 24 | html, body { 25 | height: 100%; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | display: flex; 31 | flex-direction: column; 32 | } 33 | 34 | .content { 35 | flex: 1 0 auto; 36 | } 37 | 38 | .footer { 39 | padding: 20px; 40 | flex-shrink: 0; 41 | } 42 | 43 | .page-content { 44 | margin-left: auto; 45 | margin-right: auto; 46 | max-width: 600px; 47 | padding: 0px 20px; 48 | } 49 | 50 | .center { 51 | display: flex; 52 | } 53 | 54 | .center input { 55 | margin-left: auto; 56 | margin-right: auto; 57 | } 58 | 59 | .form-field { 60 | margin: 10px 0px; 61 | } 62 | 63 | a { 64 | text-decoration: none; 65 | } 66 | 67 | .app-card { 68 | margin-top: 2rem; 69 | } 70 | 71 | .app-card__primary { 72 | padding: 1rem; 73 | } 74 | 75 | .hidden { 76 | display: none; 77 | } 78 | 79 | .github-ribbon { 80 | position: absolute; 81 | top: 0; 82 | right: 0; 83 | z-index: 4; 84 | } 85 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | helper_method :current_user 5 | 6 | private 7 | 8 | def sign_in(user) 9 | session[:user_id] = user.id 10 | end 11 | 12 | def sign_out 13 | session[:user_id] = nil 14 | end 15 | 16 | def current_user 17 | @current_user ||= 18 | if session[:user_id] 19 | User.find_by(id: session[:user_id]) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/credentials_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CredentialsController < ApplicationController 4 | def create 5 | create_options = WebAuthn::Credential.options_for_create( 6 | user: { 7 | id: current_user.webauthn_id, 8 | name: current_user.username, 9 | }, 10 | exclude: current_user.credentials.pluck(:external_id), 11 | authenticator_selection: { user_verification: "required" } 12 | ) 13 | 14 | session[:current_registration] = { challenge: create_options.challenge } 15 | 16 | respond_to do |format| 17 | format.json { render json: create_options } 18 | end 19 | end 20 | 21 | def callback 22 | webauthn_credential = WebAuthn::Credential.from_create(params) 23 | 24 | begin 25 | webauthn_credential.verify(session[:current_registration]["challenge"], user_verification: true) 26 | 27 | credential = current_user.credentials.find_or_initialize_by( 28 | external_id: Base64.strict_encode64(webauthn_credential.raw_id) 29 | ) 30 | 31 | if credential.update( 32 | nickname: params[:credential_nickname], 33 | public_key: webauthn_credential.public_key, 34 | sign_count: webauthn_credential.sign_count 35 | ) 36 | render json: { status: "ok" }, status: :ok 37 | else 38 | render json: "Couldn't add your Security Key", status: :unprocessable_entity 39 | end 40 | rescue WebAuthn::Error => e 41 | render json: "Verification failed: #{e.message}", status: :unprocessable_entity 42 | ensure 43 | session.delete(:current_registration) 44 | end 45 | end 46 | 47 | def destroy 48 | if current_user&.can_delete_credentials? 49 | current_user.credentials.destroy(params[:id]) 50 | end 51 | 52 | redirect_to root_path 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HomeController < ApplicationController 4 | before_action :enforce_current_user 5 | 6 | def index 7 | end 8 | 9 | private 10 | 11 | def enforce_current_user 12 | if current_user.blank? 13 | redirect_to new_session_path 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RegistrationsController < ApplicationController 4 | def new 5 | end 6 | 7 | def create 8 | user = User.new(username: params[:registration][:username]) 9 | 10 | create_options = WebAuthn::Credential.options_for_create( 11 | user: { 12 | name: params[:registration][:username], 13 | id: user.webauthn_id 14 | }, 15 | authenticator_selection: { user_verification: "required" } 16 | ) 17 | 18 | if user.valid? 19 | session[:current_registration] = { challenge: create_options.challenge, user_attributes: user.attributes } 20 | 21 | respond_to do |format| 22 | format.json { render json: create_options } 23 | end 24 | else 25 | respond_to do |format| 26 | format.json { render json: { errors: user.errors.full_messages }, status: :unprocessable_entity } 27 | end 28 | end 29 | end 30 | 31 | def callback 32 | webauthn_credential = WebAuthn::Credential.from_create(params) 33 | 34 | user = User.new(session[:current_registration]["user_attributes"]) 35 | 36 | begin 37 | webauthn_credential.verify(session[:current_registration]["challenge"], user_verification: true) 38 | 39 | user.credentials.build( 40 | external_id: Base64.strict_encode64(webauthn_credential.raw_id), 41 | nickname: params[:credential_nickname], 42 | public_key: webauthn_credential.public_key, 43 | sign_count: webauthn_credential.sign_count 44 | ) 45 | 46 | if user.save 47 | sign_in(user) 48 | 49 | render json: { status: "ok" }, status: :ok 50 | else 51 | render json: "Couldn't register your Security Key", status: :unprocessable_entity 52 | end 53 | rescue WebAuthn::Error => e 54 | render json: "Verification failed: #{e.message}", status: :unprocessable_entity 55 | ensure 56 | session.delete(:current_registration) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SessionsController < ApplicationController 4 | def new 5 | end 6 | 7 | def create 8 | user = User.find_by(username: session_params[:username]) 9 | 10 | if user 11 | get_options = WebAuthn::Credential.options_for_get( 12 | allow: user.credentials.pluck(:external_id), 13 | user_verification: "required" 14 | ) 15 | 16 | session[:current_authentication] = { challenge: get_options.challenge, username: session_params[:username] } 17 | 18 | respond_to do |format| 19 | format.json { render json: get_options } 20 | end 21 | else 22 | respond_to do |format| 23 | format.json { render json: { errors: ["Username doesn't exist"] }, status: :unprocessable_entity } 24 | end 25 | end 26 | end 27 | 28 | def callback 29 | webauthn_credential = WebAuthn::Credential.from_get(params) 30 | 31 | user = User.find_by(username: session[:current_authentication]["username"]) 32 | raise "user #{session[:current_authentication]["username"]} never initiated sign up" unless user 33 | 34 | credential = user.credentials.find_by(external_id: Base64.strict_encode64(webauthn_credential.raw_id)) 35 | 36 | begin 37 | webauthn_credential.verify( 38 | session[:current_authentication]["challenge"], 39 | public_key: credential.public_key, 40 | sign_count: credential.sign_count, 41 | user_verification: true, 42 | ) 43 | 44 | credential.update!(sign_count: webauthn_credential.sign_count) 45 | sign_in(user) 46 | 47 | render json: { status: "ok" }, status: :ok 48 | rescue WebAuthn::Error => e 49 | render json: "Verification failed: #{e.message}", status: :unprocessable_entity 50 | ensure 51 | session.delete(:current_authentication) 52 | end 53 | end 54 | 55 | def destroy 56 | sign_out 57 | 58 | redirect_to root_path 59 | end 60 | 61 | private 62 | 63 | def session_params 64 | params.require(:session).permit(:username) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "controllers" 3 | import "credential" 4 | import "messenger" 5 | import Rails from "@rails/ujs"; 6 | 7 | Rails.start(); 8 | -------------------------------------------------------------------------------- /app/javascript/controllers/add_credential_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import * as Credential from "credential"; 3 | 4 | export default class extends Controller { 5 | create(event) { 6 | var [data, status, xhr] = event.detail; 7 | var credentialOptions = data; 8 | var credential_nickname = event.target.querySelector("input[name='credential[nickname]']").value; 9 | var callback_url = `/credentials/callback?credential_nickname=${credential_nickname}` 10 | 11 | Credential.create(encodeURI(callback_url), credentialOptions); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/javascript/controllers/feature_detection_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import { supported as WebAuthnSupported } from "@github/webauthn-json"; 3 | 4 | export default class extends Controller { 5 | static targets = ["message"] 6 | 7 | connect() { 8 | if (!WebAuthnSupported()) { 9 | this.messageTarget.innerHTML = "This browser doesn't support WebAuthn API"; 10 | this.element.classList.remove("hidden"); 11 | } else { 12 | PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => { 13 | if (!available) { 14 | this.messageTarget.innerHTML = "We couldn't detect a user-verifying platform authenticator"; 15 | this.element.classList.remove("hidden"); 16 | } 17 | }); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "controllers/application" 4 | 5 | // Eager load all controllers defined in the import map under controllers/**/*_controller 6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 7 | eagerLoadControllersFrom("controllers", application) 8 | 9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) 10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" 11 | // lazyLoadControllersFrom("controllers", application) 12 | -------------------------------------------------------------------------------- /app/javascript/controllers/menu_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import { MDCMenu, Corner as MDCMenu_Corner } from '@material/menu'; 3 | 4 | export default class extends Controller { 5 | static targets = ["openable"] 6 | 7 | open() { 8 | let menu = new MDCMenu(this.openableTarget); 9 | menu.open = !menu.open; 10 | menu.setAnchorCorner(MDCMenu_Corner.BOTTOM_START); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/javascript/controllers/new_registration_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import * as Credential from "credential"; 3 | 4 | import { MDCTextField } from '@material/textfield'; 5 | 6 | export default class extends Controller { 7 | static targets = ["usernameField"] 8 | 9 | create(event) { 10 | var [data, status, xhr] = event.detail; 11 | console.log(data); 12 | var credentialOptions = data; 13 | 14 | // Registration 15 | if (credentialOptions["user"]) { 16 | var credential_nickname = event.target.querySelector("input[name='registration[nickname]']").value; 17 | var callback_url = `/registration/callback?credential_nickname=${credential_nickname}` 18 | 19 | Credential.create(encodeURI(callback_url), credentialOptions); 20 | } 21 | } 22 | 23 | error(event) { 24 | let response = event.detail[0]; 25 | let usernameField = new MDCTextField(this.usernameFieldTarget); 26 | usernameField.valid = false; 27 | usernameField.helperTextContent = response["errors"][0]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/javascript/controllers/new_session_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import * as Credential from "credential"; 3 | 4 | import { MDCTextField } from '@material/textfield'; 5 | 6 | export default class extends Controller { 7 | static targets = ["usernameField"] 8 | 9 | create(event) { 10 | var [data, status, xhr] = event.detail; 11 | console.log(data); 12 | var credentialOptions = data; 13 | Credential.get(credentialOptions); 14 | } 15 | 16 | error(event) { 17 | let response = event.detail[0]; 18 | let usernameField = new MDCTextField(this.usernameFieldTarget); 19 | usernameField.valid = false; 20 | usernameField.helperTextContent = response["errors"][0]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/javascript/controllers/textfield_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import { MDCTextField } from '@material/textfield'; 3 | 4 | export default class extends Controller { 5 | connect() { 6 | new MDCTextField(this.element); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/javascript/credential.js: -------------------------------------------------------------------------------- 1 | import * as WebAuthnJSON from "@github/webauthn-json" 2 | import { showMessage } from "messenger"; 3 | 4 | function getCSRFToken() { 5 | var CSRFSelector = document.querySelector('meta[name="csrf-token"]') 6 | if (CSRFSelector) { 7 | return CSRFSelector.getAttribute("content") 8 | } else { 9 | return null 10 | } 11 | } 12 | 13 | function callback(url, body) { 14 | fetch(url, { 15 | method: "POST", 16 | body: JSON.stringify(body), 17 | headers: { 18 | "Content-Type": "application/json", 19 | "Accept": "application/json", 20 | "X-CSRF-Token": getCSRFToken() 21 | }, 22 | credentials: 'same-origin' 23 | }).then(function(response) { 24 | if (response.ok) { 25 | window.location.replace("/") 26 | } else if (response.status < 500) { 27 | response.text().then(showMessage); 28 | } else { 29 | showMessage("Sorry, something wrong happened."); 30 | } 31 | }); 32 | } 33 | 34 | function create(callbackUrl, credentialOptions) { 35 | WebAuthnJSON.create({ "publicKey": credentialOptions }).then(function(credential) { 36 | callback(callbackUrl, credential); 37 | }).catch(function(error) { 38 | showMessage(error); 39 | }); 40 | 41 | console.log("Creating new public key credential..."); 42 | } 43 | 44 | function get(credentialOptions) { 45 | WebAuthnJSON.get({ "publicKey": credentialOptions }).then(function(credential) { 46 | callback("/session/callback", credential); 47 | }).catch(function(error) { 48 | showMessage(error); 49 | }); 50 | 51 | console.log("Getting public key credential..."); 52 | } 53 | 54 | export { create, get } 55 | -------------------------------------------------------------------------------- /app/javascript/messenger.js: -------------------------------------------------------------------------------- 1 | import { MDCSnackbar } from '@material/snackbar'; 2 | 3 | function showMessage(message) { 4 | const snackbar = new MDCSnackbar(document.querySelector(".js-messenger")) 5 | 6 | snackbar.labelText = message; 7 | snackbar.timeoutMs = 10000; 8 | snackbar.open(); 9 | } 10 | 11 | export { showMessage } 12 | -------------------------------------------------------------------------------- /app/javascript/stub_credentials.js: -------------------------------------------------------------------------------- 1 | import sinon from "sinon" 2 | 3 | window.sinon = sinon 4 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | end 5 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: 'from@example.com' 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | primary_abstract_class 5 | end 6 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/credential.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Credential < ApplicationRecord 4 | validates :external_id, :public_key, :nickname, :sign_count, presence: true 5 | validates :external_id, uniqueness: true 6 | validates :sign_count, 7 | numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: (2**32) - 1 } 8 | end 9 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | CREDENTIAL_MIN_AMOUNT = 1 5 | 6 | has_many :credentials, dependent: :destroy 7 | 8 | validates :username, presence: true, uniqueness: true 9 | 10 | after_initialize do 11 | self.webauthn_id ||= WebAuthn.generate_user_id 12 | end 13 | 14 | def can_delete_credentials? 15 | credentials.size > CREDENTIAL_MIN_AMOUNT 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/views/application/_feature_detection.html.erb: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /app/views/application/_github_ribbon.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to "https://github.com/cedarcode/webauthn-rails-demo-app", class: "github-ribbon" do %> 2 | <%= image_tag "https://github.blog/wp-content/uploads/2008/12/forkme_right_gray_6d6d6d.png?resize=140%2C140", size: "140", class: "attachment-full size-full", alt: "Fork me on GitHub", data: { recalc_dims: "1" } %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /app/views/application/_rollbar_js.html.erb: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Your Security Keys

3 | 24 | 25 |
26 | <%= form_with scope: :credential, url: credentials_path, local: false, data: { action: "ajax:success->add-credential#create" } do |form| %> 27 |
28 |
29 | <%= form.text_field :nickname, class: "mdc-text-field__input", placeholder: "New Security Key nickname", required: true %> 30 |
31 |
32 |
33 | 34 |
35 | <%= form.submit "Add Security Key", class: "mdc-button mdc-button--unelevated" %> 36 |
37 | <% end %> 38 |
39 |
40 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebAuthn Rails Demo App 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | 11 | 12 | <% if Rails.env.production? %> 13 | <%= render "rollbar_js" %> 14 | <% end %> 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | <%= stylesheet_link_tag 'https://unpkg.com/material-components-web@4.0.0/dist/material-components-web.min.css' %> 27 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %> 28 | <%= stylesheet_link_tag "https://fonts.googleapis.com/css?family=Roboto:300,400,500" %> 29 | <%= stylesheet_link_tag "https://fonts.googleapis.com/icon?family=Material+Icons" %> 30 | 31 | <%= javascript_importmap_tags %> 32 | 33 | <% if Rails.env.test? %> 34 | <%= javascript_import_module_tag 'stub_credentials' %> 35 | <% end %> 36 | 37 | 38 | 39 |
40 |
41 |
42 | WebAuthn Rails Demo App 43 |
44 | 45 | <% if current_user %> 46 | 71 | <% end %> 72 |
73 |
74 | 75 |
76 |
77 | <%= yield %> 78 |
79 |
80 | 81 | 87 | 88 |
89 |
90 |
91 |
92 | 93 |
94 | 95 |
96 |
97 |
98 | 99 | 100 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/registrations/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: "application/github_ribbon" %> 2 | <%= render partial: 'application/feature_detection' %> 3 | 4 |
5 |
6 |

Register

7 |
8 | <%= form_with scope: :registration, url: registration_path, local: false, id: "new-registration", data: { controller: "new-registration", action: "ajax:success->new-registration#create ajax:error->new-registration#error" } do |form| %> 9 |
10 |
11 | <%= form.text_field :username, class: "mdc-text-field__input", placeholder: "Username", required: true, autocapitalize: "none", "aria-controls" => "username-helper-text" %> 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 | <%= form.text_field :nickname, class: "mdc-text-field__input", placeholder: "Security Key nickname", required: true %> 24 |
25 |
26 |
27 | 28 |
29 | <%= form.submit "Register using WebAuthn", class: "mdc-button mdc-button--raised" %> 30 |
31 | <% end %> 32 |
33 |
34 |
35 |
36 |
37 | <%= link_to 'Sign in', new_session_path, class: "mdc-button mdc-card__action mdc-card__action--button mdc-ripple-upgraded" %> 38 |
39 |
40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: "application/github_ribbon" %> 2 | <%= render partial: 'application/feature_detection' %> 3 | 4 |
5 |
6 |

Sign in

7 |
8 | <%= form_with scope: :session, url: session_path, local: false, id: "new-session", data: { controller: "new-session", action: "ajax:success->new-session#create ajax:error->new-session#error" } do |form| %> 9 |
10 |
11 | <%= form.text_field :username, class: "mdc-text-field__input", placeholder: "Username", required: true, autocapitalize: "none", "aria-controls" => "username-helper-text" %> 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 | <%= form.submit "Sign in using WebAuthn", class: "mdc-button mdc-button--raised" %> 23 |
24 | <% end %> 25 |
26 |
27 |
28 |
29 |
30 | <%= link_to 'Register', new_registration_path, class: "mdc-button mdc-card__action mdc-card__action--button mdc-ripple-upgraded" %> 31 |
32 |
33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Enable jemalloc for reduced memory usage and latency. 4 | if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then 5 | export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)" 6 | fi 7 | 8 | # If running the rails server then create or migrate existing database 9 | if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then 10 | ./bin/rails db:prepare 11 | 12 | file="./tmp/pids/server.pid" 13 | if [ -f "$file" ] ; then 14 | rm "$file" 15 | fi 16 | fi 17 | 18 | exec "${@}" 19 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path('..', __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?('config/database.yml') 20 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! 'bin/rails db:prepare' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | unless ARGV.include?("--skip-server") 30 | puts "\n== Starting development server ==" 31 | STDOUT.flush # flush the output before exec(2) so that it displays 32 | exec "bin/dev" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | puts "\n== Updating database ==" 21 | system! 'bin/rails db:migrate' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | require "sprockets/railtie" 16 | require "rails/test_unit/railtie" 17 | 18 | # Require the gems listed in Gemfile, including any gems 19 | # you've limited to :test, :development, or :production. 20 | Bundler.require(*Rails.groups) 21 | 22 | module WebauthnApp 23 | class Application < Rails::Application 24 | # Initialize configuration defaults for originally generated Rails version. 25 | config.load_defaults 8.0 26 | 27 | # Please, add to the `ignore` list any other `lib` subdirectories that do 28 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 29 | # Common ones are `templates`, `generators`, or `middleware`, for example. 30 | config.autoload_lib(ignore: %w[assets tasks]) 31 | 32 | # Configuration for the application, engines, and railties goes here. 33 | # 34 | # These settings can be overridden in specific environments using the files 35 | # in config/environments, which are processed later. 36 | # 37 | # config.time_zone = "Central Time (US & Canada)" 38 | # config.eager_load_paths << Rails.root.join("extras") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: webauthn-app_production 11 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | DFpjzqUQYr1EI503VX+ZgABwpJ4diOr0Ubzkd76GzH5e4SRl45xue2AY25ETpqv6hH+bIAlheggTwmDCM3qZy1793rOHjKuf5Lu1hNZIB7d24m+BSxoyoWFNurO2F2BTIDrWUZO8KN064w7GlMpMRECHSHvc8aJZyHR7JGIZ32o/Zf5KmFd9cRchWE9pejdjMJAUySYPJEsJe7knVl+x23eMTtWlg1B9HK0ZjPfCuWwZEtiQe04l5UM1tZzaIokC8IP5fwtHR4QgYRPrA8g244N/PETRuNOT1hAyVwqrvvaTWiyZdYY70Hh2uJ2E4Zn5SyuLs3vBkzOPNJUGCe29HMp5ule4O+jYieJvJ6i0o9L978pY1y4Fst+XSZ92k/3bKo3MF4778gf+AecgZbbXH/2LSco4TrLSF8qTfahW/wsJD449eYdvHVVPT3WxpdLgLPMmRNwG0F+nDN/RunQd+5102k5BO/VzaNnrR56ivpPgEAXlFd7eRUQow/kkiM0m97Tu0AaLeE6fHMQzDzfxBaRfjs2eQh/eB9SkLOEfTWAkl8P8NcIuQVh+cjnqcdqcmbdQErc+a5lhr2UZ1n4gS8PYkwq6CgMifslLGLPntm6ebx8PIvLQOaw226g=--V3U6uSlBXvLmxpwM--G4Vit/cYx2o1hIf2KPnlWg== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: storage/production.sqlite3 26 | -------------------------------------------------------------------------------- /config/deploy.yml: -------------------------------------------------------------------------------- 1 | service: webauthn-rails-demo 2 | 3 | image: webauthn-rails-demo 4 | 5 | servers: 6 | web: 7 | hosts: 8 | - 3.233.189.168 9 | options: 10 | memory: 200MiB 11 | 12 | proxy: 13 | host: webauthn.cedarcode.com 14 | ssl: true 15 | app_port: 3000 16 | 17 | registry: 18 | server: 864100757851.dkr.ecr.us-east-1.amazonaws.com 19 | username: AWS 20 | password: <%= %x(aws ecr get-login-password) %> 21 | 22 | env: 23 | clear: 24 | RACK_ENV: production 25 | RAILS_ENV: production 26 | RAILS_LOG_TO_STDOUT: enabled 27 | RAILS_SERVE_STATIC_FILES: enabled 28 | LANG: en_US.UTF-8 29 | secret: 30 | - RAILS_MASTER_KEY 31 | 32 | ssh: 33 | user: ubuntu 34 | 35 | builder: 36 | arch: amd64 37 | cache: 38 | type: gha 39 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | 20 | # Run rails dev:cache to toggle Action Controller caching. 21 | if Rails.root.join("tmp/caching-dev.txt").exist? 22 | config.action_controller.perform_caching = true 23 | config.action_controller.enable_fragment_cache_logging = true 24 | 25 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 26 | else 27 | config.action_controller.perform_caching = false 28 | end 29 | 30 | # Change to :null_store to avoid any caching. 31 | config.cache_store = :memory_store 32 | 33 | # Don't care if the mailer can't send. 34 | config.action_mailer.raise_delivery_errors = false 35 | 36 | # Make template changes take effect immediately. 37 | config.action_mailer.perform_caching = false 38 | 39 | # Set localhost to be used by links generated in mailer templates. 40 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 41 | 42 | # Print deprecation notices to the Rails logger. 43 | config.active_support.deprecation = :log 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Append comments with runtime information tags to SQL queries in logs. 52 | config.active_record.query_log_tags_enabled = true 53 | 54 | # Highlight code that enqueued background job in logs. 55 | config.active_job.verbose_enqueue_logs = true 56 | 57 | # Raises error for missing translations. 58 | # config.i18n.raise_on_missing_translations = true 59 | 60 | # Annotate rendered view with file names. 61 | # config.action_view.annotate_rendered_view_with_filenames = true 62 | 63 | # Uncomment if you wish to allow Action Cable access from any origin. 64 | # config.action_cable.disable_request_forgery_protection = true 65 | 66 | # Raise error when a before_action's only/except options reference missing actions. 67 | config.action_controller.raise_on_missing_callback_actions = true 68 | 69 | # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. 70 | # config.generators.apply_rubocop_autocorrect_after_generate! 71 | 72 | config.webauthn_origin = 'http://localhost:3000' 73 | end 74 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = 'http://assets.example.com' 23 | 24 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 25 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 26 | config.assume_ssl = true 27 | 28 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 29 | config.force_ssl = true 30 | 31 | # Skip http-to-https redirect for the default health check endpoint. 32 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 33 | 34 | # Log to STDOUT with the current request id as a default log tag. 35 | config.log_tags = [ :request_id ] 36 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 37 | 38 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 39 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 40 | 41 | # Prevent health checks from clogging up the logs. 42 | config.silence_healthcheck_path = "/up" 43 | 44 | # Don't log any deprecations. 45 | config.active_support.report_deprecations = false 46 | 47 | # Replace the default in-process memory cache store with a durable alternative. 48 | # config.cache_store = :mem_cache_store 49 | 50 | # Replace the default in-process and non-durable queuing backend for Active Job. 51 | # config.active_job.queue_adapter = :resque 52 | # config.active_job.queue_name_prefix = "webauthn_app_production" 53 | 54 | config.action_mailer.perform_caching = false 55 | 56 | # Ignore bad email addresses and do not raise email delivery errors. 57 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 58 | # config.action_mailer.raise_delivery_errors = false 59 | 60 | # Set host to be used by links generated in mailer templates. 61 | config.action_mailer.default_url_options = { host: "example.com" } 62 | 63 | # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. 64 | # config.action_mailer.smtp_settings = { 65 | # user_name: Rails.application.credentials.dig(:smtp, :user_name), 66 | # password: Rails.application.credentials.dig(:smtp, :password), 67 | # address: "smtp.example.com", 68 | # port: 587, 69 | # authentication: :plain 70 | # } 71 | 72 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 73 | # the I18n.default_locale when a translation cannot be found). 74 | config.i18n.fallbacks = true 75 | 76 | # Do not dump schema after migrations. 77 | config.active_record.dump_schema_after_migration = false 78 | 79 | # Only use :id for inspections in production. 80 | config.active_record.attributes_for_inspect = [ :id ] 81 | 82 | # Enable DNS rebinding protection and other `Host` header attacks. 83 | # config.hosts = [ 84 | # "example.com", # Allow requests from example.com 85 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 86 | # ] 87 | # Skip DNS rebinding protection for the default health check endpoint. 88 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 89 | 90 | config.webauthn_origin = 'https://webauthn.cedarcode.com' 91 | 92 | config.yjit = false 93 | end 94 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | 22 | # Show full error reports. 23 | config.consider_all_requests_local = true 24 | config.cache_store = :null_store 25 | 26 | # Render exception templates for rescuable exceptions and raise for other exceptions. 27 | config.action_dispatch.show_exceptions = :rescuable 28 | 29 | # Disable request forgery protection in test environment. 30 | config.action_controller.allow_forgery_protection = false 31 | 32 | # Tell Action Mailer not to deliver emails to the real world. 33 | # The :test delivery method accumulates sent emails in the 34 | # ActionMailer::Base.deliveries array. 35 | config.action_mailer.delivery_method = :test 36 | 37 | # Set host to be used by links generated in mailer templates. 38 | config.action_mailer.default_url_options = { host: "example.com" } 39 | 40 | # Print deprecation notices to the stderr. 41 | config.active_support.deprecation = :stderr 42 | 43 | # Raises error for missing translations. 44 | # config.i18n.raise_on_missing_translations = true 45 | 46 | # Annotate rendered view with file names. 47 | # config.action_view.annotate_rendered_view_with_filenames = true 48 | 49 | # Raise error when a before_action's only/except options reference missing actions. 50 | config.action_controller.raise_on_missing_callback_actions = true 51 | 52 | config.webauthn_origin = 'http://localhost:3030' 53 | end 54 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application", preload: true 4 | pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true 5 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true 6 | pin_all_from "app/javascript/controllers", under: "controllers" 7 | pin "@rails/ujs", to: "https://ga.jspm.io/npm:@rails/ujs@7.1.2/app/assets/javascripts/rails-ujs.esm.js" 8 | pin "credential" 9 | pin "messenger" 10 | pin "stub_credentials" if Rails.env.test? 11 | pin "@material/list", to: "https://ga.jspm.io/npm:@material/list@4.0.0/dist/mdc.list.js" 12 | pin "@material/menu", to: "https://ga.jspm.io/npm:@material/menu@4.0.0/dist/mdc.menu.js" 13 | pin "@material/snackbar", to: "https://ga.jspm.io/npm:@material/snackbar@4.0.0/dist/mdc.snackbar.js" 14 | pin "@material/textfield", to: "https://ga.jspm.io/npm:@material/textfield@4.0.0/dist/mdc.textfield.js" 15 | pin "@material/top-app-bar", to: "https://ga.jspm.io/npm:@material/top-app-bar@4.0.0/dist/mdc.topAppBar.js" 16 | pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js" 17 | 18 | # sinon 19 | pin "sinon", to: "https://ga.jspm.io/npm:sinon@9.0.2/lib/sinon.js" 20 | pin "@sinonjs/commons", to: "https://ga.jspm.io/npm:@sinonjs/commons@1.8.6/lib/index.js" 21 | pin "@sinonjs/fake-timers", to: "https://ga.jspm.io/npm:@sinonjs/fake-timers@6.0.1/src/fake-timers-src.js" 22 | pin "@sinonjs/formatio", to: "https://ga.jspm.io/npm:@sinonjs/formatio@5.0.1/lib/formatio.js" 23 | pin "@sinonjs/samsam", to: "https://ga.jspm.io/npm:@sinonjs/samsam@5.3.1/lib/samsam.js" 24 | pin "@sinonjs/text-encoding", to: "https://ga.jspm.io/npm:@sinonjs/text-encoding@0.7.2/index.js" 25 | pin "diff", to: "https://ga.jspm.io/npm:diff@4.0.2/dist/diff.js" 26 | pin "isarray", to: "https://ga.jspm.io/npm:isarray@0.0.1/index.js" 27 | pin "just-extend", to: "https://ga.jspm.io/npm:just-extend@4.2.1/index.js" 28 | pin "lodash.get", to: "https://ga.jspm.io/npm:lodash.get@4.4.2/index.js" 29 | pin "nise", to: "https://ga.jspm.io/npm:nise@4.1.0/lib/index.js" 30 | pin "path-to-regexp", to: "https://ga.jspm.io/npm:path-to-regexp@1.8.0/index.js" 31 | pin "process", to: "https://ga.jspm.io/npm:@jspm/core@2.0.1/nodelibs/browser/process-production.js" 32 | pin "supports-color", to: "https://ga.jspm.io/npm:supports-color@7.2.0/browser.js" 33 | pin "type-detect", to: "https://ga.jspm.io/npm:type-detect@4.0.8/type-detect.js" 34 | pin "util", to: "https://ga.jspm.io/npm:@jspm/core@2.0.1/nodelibs/browser/util.js" 35 | 36 | # turbolinks 37 | pin "turbolinks", to: "https://ga.jspm.io/npm:turbolinks@5.2.0/dist/turbolinks.js" 38 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /config/initializers/rollbar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rollbar.configure do |config| 4 | # Without configuration, Rollbar is enabled in all environments. 5 | # To disable in specific environments, set config.enabled=false. 6 | 7 | config.access_token = Rails.application.credentials.rollbar_post_server_item_access_token 8 | 9 | # Here we'll disable in 'test': 10 | if Rails.env.test? || Rails.env.development? 11 | config.enabled = false 12 | end 13 | 14 | # By default, Rollbar will try to call the `current_user` controller method 15 | # to fetch the logged-in user object, and then call that object's `id` 16 | # method to fetch this property. To customize: 17 | # config.person_method = "my_current_user" 18 | # config.person_id_method = "my_id" 19 | 20 | # Additionally, you may specify the following: 21 | # config.person_username_method = "username" 22 | # config.person_email_method = "email" 23 | 24 | # If you want to attach custom data to all exception and message reports, 25 | # provide a lambda like the following. It should return a hash. 26 | # config.custom_data_method = lambda { {:some_key => "some_value" } } 27 | 28 | # Add exception class names to the exception_level_filters hash to 29 | # change the level that exception is reported at. Note that if an exception 30 | # has already been reported and logged the level will need to be changed 31 | # via the rollbar interface. 32 | # Valid levels: 'critical', 'error', 'warning', 'info', 'debug', 'ignore' 33 | # 'ignore' will cause the exception to not be reported at all. 34 | # config.exception_level_filters.merge!('MyCriticalException' => 'critical') 35 | # 36 | # You can also specify a callable, which will be called with the exception instance. 37 | # config.exception_level_filters.merge!('MyCriticalException' => lambda { |e| 'critical' }) 38 | config.exception_level_filters['ActionController::RoutingError'] = 'ignore' 39 | 40 | # Enable asynchronous reporting (uses girl_friday or Threading if girl_friday 41 | # is not installed) 42 | # config.use_async = true 43 | # Supply your own async handler: 44 | # config.async_handler = Proc.new { |payload| 45 | # Thread.new { Rollbar.process_from_async_handler(payload) } 46 | # } 47 | 48 | # Enable asynchronous reporting (using sucker_punch) 49 | # config.use_sucker_punch 50 | 51 | # Enable delayed reporting (using Sidekiq) 52 | # config.use_sidekiq 53 | # You can supply custom Sidekiq options: 54 | # config.use_sidekiq 'queue' => 'default' 55 | 56 | # If your application runs behind a proxy server, you can set proxy parameters here. 57 | # If https_proxy is set in your environment, that will be used. Settings here have precedence. 58 | # The :host key is mandatory and must include the URL scheme (e.g. 'http://'), all other fields 59 | # are optional. 60 | # 61 | # config.proxy = { 62 | # host: 'http://some.proxy.server', 63 | # port: 80, 64 | # user: 'username_if_auth_required', 65 | # password: 'password_if_auth_required' 66 | # } 67 | 68 | # If you run your staging application instance in production environment then 69 | # you'll want to override the environment reported by `Rails.env` with an 70 | # environment variable like this: `ROLLBAR_ENV=staging`. This is a recommended 71 | # setup for Heroku. See: 72 | # https://devcenter.heroku.com/articles/deploying-to-a-custom-rails-environment 73 | config.environment = Rails.application.credentials.rollbar_env.presence || Rails.env 74 | end 75 | -------------------------------------------------------------------------------- /config/initializers/webauthn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | WebAuthn.configure do |config| 4 | config.allowed_origins = [Rails.configuration.webauthn_origin] 5 | config.rp_name = "WebAuthn Rails Demo App" 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # 6 | # Puma starts a configurable number of processes (workers) and each process 7 | # serves each request in a thread from an internal thread pool. 8 | # 9 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 10 | # should only set this value when you want to run 2 or more workers. The 11 | # default is already 1. 12 | # 13 | # The ideal number of threads per worker depends both on how much time the 14 | # application spends waiting for IO operations and on how much you wish to 15 | # prioritize throughput over latency. 16 | # 17 | # As a rule of thumb, increasing the number of threads will increase how much 18 | # traffic a given process can handle (throughput), but due to CRuby's 19 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 20 | # response time (latency) of the application. 21 | # 22 | # The default is set to 3 threads as it's deemed a decent compromise between 23 | # throughput and latency for the average Rails application. 24 | # 25 | # Any libraries that use a connection pool or another resource pool should 26 | # be configured to provide at least as many connections as the number of 27 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 28 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 29 | threads threads_count, threads_count 30 | 31 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 32 | port ENV.fetch("PORT") { 3000 } 33 | 34 | # Allow puma to be restarted by `bin/rails restart` command. 35 | plugin :tmp_restart 36 | 37 | # Run the Solid Queue supervisor inside of Puma for single-server deployments 38 | plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] 39 | 40 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 41 | # In other environments, only set the PID file if requested. 42 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 43 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 5 | 6 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 7 | # Can be used by load balancers and uptime monitors to verify that the app is live. 8 | get "up" => "rails/health#show", as: :rails_health_check 9 | 10 | resource :session, only: [:new, :create, :destroy] do 11 | post :callback 12 | end 13 | 14 | resource :registration, only: [:new, :create] do 15 | post :callback 16 | end 17 | 18 | resources :credentials, only: [:create, :destroy] do 19 | post :callback, on: :collection 20 | end 21 | 22 | # post "session_callback", to: "sessions#callback" 23 | # post "registration_callback", to: "registrations#callback" 24 | root to: "home#index" 25 | end 26 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /db/migrate/20180528192606_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUsers < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :users do |t| 6 | t.string :email, index: { unique: true } 7 | t.string :current_challenge 8 | t.string :credential 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20180529223646_add_public_key_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPublicKeyToUsers < ActiveRecord::Migration[5.2] 4 | def change 5 | change_table :users do |t| 6 | t.rename :credential, :credential_id 7 | t.string :credential_public_key 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20180612192143_create_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateCredentials < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :credentials do |t| 6 | t.string :external_id 7 | t.string :public_key 8 | t.references :user, foreign_key: true 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20180612192702_remove_credentials_from_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveCredentialsFromUsers < ActiveRecord::Migration[5.2] 4 | def change 5 | remove_column :users, :credential_id, :string 6 | remove_column :users, :credential_public_key, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20180704215403_add_nickname_to_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddNicknameToCredentials < ActiveRecord::Migration[5.2] 4 | def change 5 | add_column :credentials, :nickname, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20181005235558_rename_email_to_username.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameEmailToUsername < ActiveRecord::Migration[5.2] 4 | def change 5 | rename_column :users, :email, :username 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20190829002430_add_sign_count_to_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSignCountToCredentials < ActiveRecord::Migration[6.0] 4 | def change 5 | add_column :credentials, :sign_count, :bigint, null: false, default: 0 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20191002172648_add_webauthn_id_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddWebauthnIdToUsers < ActiveRecord::Migration[6.0] 4 | def change 5 | add_column :users, :webauthn_id, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20191008202418_remove_current_challenge_from_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveCurrentChallengeFromUsers < ActiveRecord::Migration[6.0] 4 | def change 5 | remove_column :users, :current_challenge, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200411210422_add_unique_index_to_credentials_external_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUniqueIndexToCredentialsExternalId < ActiveRecord::Migration[6.0] 4 | def change 5 | add_index :credentials, :external_id, unique: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.0].define(version: 2020_04_11_210422) do 14 | create_table "credentials", force: :cascade do |t| 15 | t.string "external_id" 16 | t.string "public_key" 17 | t.integer "user_id" 18 | t.datetime "created_at", precision: nil, null: false 19 | t.datetime "updated_at", precision: nil, null: false 20 | t.string "nickname" 21 | t.bigint "sign_count", default: 0, null: false 22 | t.index ["external_id"], name: "index_credentials_on_external_id", unique: true 23 | t.index ["user_id"], name: "index_credentials_on_user_id" 24 | end 25 | 26 | create_table "users", force: :cascade do |t| 27 | t.string "username" 28 | t.datetime "created_at", precision: nil, null: false 29 | t.datetime "updated_at", precision: nil, null: false 30 | t.string "webauthn_id" 31 | t.index ["username"], name: "index_users_on_username", unique: true 32 | end 33 | 34 | add_foreign_key "credentials", "users" 35 | end 36 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file should ensure the existence of records required to run the application in every environment (production, 4 | # development, test). The code here should be idempotent so that it can be executed at any point in every environment. 5 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 6 | # 7 | # Example: 8 | # 9 | # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| 10 | # MovieGenre.find_or_create_by!(name: genre_name) 11 | # end 12 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/default.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.env.local? 4 | require 'rubocop/rake_task' 5 | RuboCop::RakeTask.new 6 | 7 | task default: [:rubocop, :test] 8 | end 9 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/log/.keep -------------------------------------------------------------------------------- /public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Your browser is not supported.
Please upgrade your browser to continue.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We’re sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/storage/.keep -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 6 | driven_by :selenium, using: (ENV["TEST_BROWSER"] || :chrome).to_sym, screen_size: [1400, 1400] 7 | 8 | setup do 9 | Capybara.app_host = Rails.configuration.webauthn_origin 10 | Capybara.server_host = "localhost" 11 | Capybara.server_port = 3030 12 | Capybara.default_max_wait_time = 20 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/controllers/.keep -------------------------------------------------------------------------------- /test/controllers/registrations_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require "webauthn/fake_client" 5 | 6 | class RegistrationsControllerTest < ActionDispatch::IntegrationTest 7 | test "should initiate registration successfully" do 8 | post registration_url, params: { registration: { username: "alice" }, format: :json } 9 | 10 | assert_response :success 11 | end 12 | 13 | test "should return error if registrating taken username" do 14 | User.create!(username: "alice") 15 | 16 | post registration_url, params: { registration: { username: "alice" }, format: :json } 17 | 18 | assert_response :unprocessable_entity 19 | assert_equal ["Username has already been taken"], response.parsed_body["errors"] 20 | end 21 | 22 | test "should return error if registrating blank username" do 23 | post registration_url, params: { registration: { username: "" }, format: :json } 24 | 25 | assert_response :unprocessable_entity 26 | assert_equal ["Username can't be blank"], response.parsed_body["errors"] 27 | end 28 | 29 | test "should return error if registering existing credential" do 30 | raw_challenge = SecureRandom.random_bytes(32) 31 | challenge = WebAuthn.configuration.encoder.encode(raw_challenge) 32 | 33 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance(:raw_challenge, raw_challenge) do 34 | post registration_url, params: { registration: { username: "alice" }, format: :json } 35 | 36 | assert_response :success 37 | end 38 | 39 | public_key_credential = 40 | WebAuthn::FakeClient 41 | .new(Rails.configuration.webauthn_origin) 42 | .create(challenge:, user_verified: true) 43 | 44 | webauthn_credential = WebAuthn::Credential.from_create(public_key_credential) 45 | 46 | User.create!( 47 | username: "bob", 48 | credentials: [ 49 | Credential.new( 50 | external_id: Base64.strict_encode64(webauthn_credential.raw_id), 51 | nickname: "Bob's USB Key", 52 | public_key: webauthn_credential.public_key, 53 | sign_count: webauthn_credential.sign_count 54 | ) 55 | ] 56 | ) 57 | 58 | assert_no_difference -> { User.count } do 59 | post( 60 | callback_registration_url, 61 | params: { credential_nickname: "USB Key" }.merge(public_key_credential) 62 | ) 63 | end 64 | 65 | assert_response :unprocessable_entity 66 | assert_equal "Couldn't register your Security Key", response.body 67 | end 68 | 69 | test "should register successfully" do 70 | raw_challenge = SecureRandom.random_bytes(32) 71 | challenge = WebAuthn.configuration.encoder.encode(raw_challenge) 72 | 73 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance(:raw_challenge, raw_challenge) do 74 | post registration_url, params: { registration: { username: "alice" }, format: :json } 75 | 76 | assert_response :success 77 | end 78 | 79 | public_key_credential = 80 | WebAuthn::FakeClient 81 | .new(Rails.configuration.webauthn_origin) 82 | .create(challenge:, user_verified: true) 83 | 84 | assert_difference 'User.count', +1 do 85 | assert_difference 'Credential.count', +1 do 86 | post( 87 | callback_registration_url, 88 | params: { credential_nickname: "USB Key" }.merge(public_key_credential) 89 | ) 90 | end 91 | end 92 | 93 | assert_response :success 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/controllers/sessions_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class SessionsControllerTest < ActionDispatch::IntegrationTest 6 | test "should initiate registration successfully" do 7 | User.create!(username: "alice") 8 | 9 | post session_url, params: { session: { username: "alice" }, format: :json } 10 | 11 | assert_response :success 12 | end 13 | 14 | test "should return error if creating session with inexisting username" do 15 | post session_url, params: { session: { username: "alice" }, format: :json } 16 | 17 | assert_response :unprocessable_entity 18 | assert_equal ["Username doesn't exist"], response.parsed_body["errors"] 19 | end 20 | 21 | test "should return error if creating session with blank username" do 22 | post session_url, params: { session: { username: "" }, format: :json } 23 | 24 | assert_response :unprocessable_entity 25 | assert_equal ["Username doesn't exist"], response.parsed_body["errors"] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/fixtures/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/fixtures/.keep -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/fixtures/files/.keep -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/helpers/.keep -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/models/.keep -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/test/system/.keep -------------------------------------------------------------------------------- /test/system/add_credential_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "application_system_test_case" 4 | require "webauthn/fake_client" 5 | 6 | class AddCredentialTest < ApplicationSystemTestCase 7 | test "add credentials" do 8 | fake_origin = Rails.configuration.webauthn_origin 9 | fake_client = WebAuthn::FakeClient.new(fake_origin, encoding: false) 10 | fixed_challenge = SecureRandom.random_bytes(32) 11 | 12 | visit new_registration_path 13 | 14 | fake_credentials = fake_client.create(challenge: fixed_challenge, user_verified: true) 15 | stub_create(fake_credentials) 16 | 17 | fill_in "registration_username", with: "User1" 18 | fill_in "Security Key nickname", with: "USB key" 19 | 20 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance :raw_challenge, fixed_challenge do 21 | click_on "Register using WebAuthn" 22 | # wait for async response 23 | assert_button 'account_circle' 24 | end 25 | 26 | fake_credentials = fake_client.create(challenge: fixed_challenge, user_verified: true) 27 | stub_create(fake_credentials) 28 | 29 | fill_in("credential_nickname", with: "Touch ID") 30 | 31 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance :raw_challenge, fixed_challenge do 32 | click_on "Add Security Key" 33 | # wait for async response 34 | assert_text 'Touch ID' 35 | end 36 | 37 | assert_current_path "/" 38 | assert_text 'USB key' 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/system/registration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "application_system_test_case" 4 | require "webauthn/fake_client" 5 | 6 | class RegistrationTest < ApplicationSystemTestCase 7 | test "register user" do 8 | fake_origin = Rails.configuration.webauthn_origin 9 | fake_client = WebAuthn::FakeClient.new(fake_origin, encoding: false) 10 | fixed_challenge = SecureRandom.random_bytes(32) 11 | 12 | visit new_registration_path 13 | 14 | fake_credentials = fake_client.create(challenge: fixed_challenge, user_verified: true) 15 | stub_create(fake_credentials) 16 | 17 | fill_in "registration_username", with: "User1" 18 | fill_in "Security Key nickname", with: "USB key" 19 | 20 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance :raw_challenge, fixed_challenge do 21 | click_on "Register using WebAuthn" 22 | # wait for async response 23 | assert_button "account_circle" 24 | end 25 | 26 | assert_current_path "/" 27 | assert_text 'USB key' 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/system/sign_in_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "application_system_test_case" 4 | require "webauthn/fake_client" 5 | 6 | class SignInTest < ApplicationSystemTestCase 7 | test "register and then sign in" do 8 | fake_origin = Rails.configuration.webauthn_origin 9 | fake_client = WebAuthn::FakeClient.new(fake_origin, encoding: false) 10 | fixed_challenge = SecureRandom.random_bytes(32) 11 | 12 | visit new_registration_path 13 | 14 | fake_credentials = fake_client.create(challenge: fixed_challenge, user_verified: true) 15 | stub_create(fake_credentials) 16 | 17 | fill_in "registration_username", with: "User1" 18 | fill_in "Security Key nickname", with: "USB key" 19 | 20 | WebAuthn::PublicKeyCredential::CreationOptions.stub_any_instance :raw_challenge, fixed_challenge do 21 | click_on "Register using WebAuthn" 22 | # wait for async response 23 | assert_button "account_circle" 24 | end 25 | 26 | click_button "account_circle" 27 | click_on "Sign out" 28 | visit new_session_path 29 | 30 | fake_assertion = fake_client.get(challenge: fixed_challenge, user_verified: true) 31 | stub_get(fake_assertion) 32 | 33 | fill_in "Username", with: "User1" 34 | 35 | WebAuthn::PublicKeyCredential::RequestOptions.stub_any_instance :raw_challenge, fixed_challenge do 36 | click_button "Sign in using WebAuthn" 37 | # wait for async response 38 | assert_button "account_circle" 39 | end 40 | 41 | assert_current_path "/" 42 | assert_text 'USB key' 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require_relative '../config/environment' 5 | require 'rails/test_help' 6 | 7 | class ActiveSupport::TestCase 8 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 9 | fixtures :all 10 | 11 | def stub_create(fake_credential) 12 | # Encode binary fields to use in script 13 | encode(fake_credential, "rawId") 14 | encode(fake_credential["response"], "attestationObject") 15 | 16 | # Parse to avoid escaping already escaped characters 17 | fake_credential["response"]["clientDataJSON"] = JSON.parse(fake_credential["response"]["clientDataJSON"]) 18 | 19 | page.execute_script(<<-SCRIPT) 20 | function encode(input) { 21 | return Uint8Array.from(input, c => c.charCodeAt(0)); 22 | } 23 | 24 | let fakeCredential = JSON.parse('#{fake_credential.to_json}'); 25 | 26 | fakeCredential.rawId = encode(atob(fakeCredential.rawId)); 27 | fakeCredential.response.attestationObject = encode(atob(fakeCredential.response.attestationObject)); 28 | fakeCredential.response.clientDataJSON = encode(JSON.stringify(fakeCredential.response.clientDataJSON)); 29 | fakeCredential.getClientExtensionResults = function() { return {} }; 30 | 31 | window.sinon.stub(navigator.credentials, 'create').resolves(fakeCredential); 32 | SCRIPT 33 | end 34 | 35 | def stub_get(fake_credential) 36 | # Encode binary fields to use in script 37 | encode(fake_credential, "rawId") 38 | encode(fake_credential["response"], "authenticatorData") 39 | encode(fake_credential["response"], "signature") 40 | 41 | # Parse to avoid escaping already escaped characters 42 | fake_credential["response"]["clientDataJSON"] = JSON.parse(fake_credential["response"]["clientDataJSON"]) 43 | 44 | page.execute_script(<<-SCRIPT) 45 | function encode(input) { 46 | return Uint8Array.from(input, c => c.charCodeAt(0)); 47 | } 48 | 49 | let fakeCredential = JSON.parse('#{fake_credential.to_json}'); 50 | 51 | fakeCredential.rawId = encode(atob(fakeCredential.rawId)); 52 | fakeCredential.response.authenticatorData = encode(atob(fakeCredential.response.authenticatorData)); 53 | fakeCredential.response.clientDataJSON = encode(JSON.stringify(fakeCredential.response.clientDataJSON)); 54 | fakeCredential.response.signature = encode(atob(fakeCredential.response.signature)); 55 | fakeCredential.getClientExtensionResults = function() { return {} }; 56 | 57 | window.sinon.stub(navigator.credentials, 'get').resolves(fakeCredential); 58 | SCRIPT 59 | end 60 | 61 | def encode(hash, key) 62 | hash[key] = Base64.strict_encode64(hash[key]) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/tmp/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/vendor/.keep -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedarcode/webauthn-rails-demo-app/3f6928f61af91174639f3a9cba2e989da5b4d96a/vendor/javascript/.keep --------------------------------------------------------------------------------