├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .reek.yml ├── .rspec ├── .rubocop.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── apple_auth.gemspec ├── bin ├── console └── setup ├── lib ├── apple_auth.rb ├── apple_auth │ ├── base.rb │ ├── base │ │ └── version.rb │ ├── config.rb │ ├── helpers │ │ ├── conditions │ │ │ ├── aud_condition.rb │ │ │ ├── exp_condition.rb │ │ │ ├── iat_condition.rb │ │ │ ├── iss_condition.rb │ │ │ └── jwt_validation_error.rb │ │ ├── jwt_conditions.rb │ │ ├── jwt_decoder.rb │ │ └── jwt_server_conditions.rb │ ├── server_identity.rb │ ├── token.rb │ └── user_identity.rb └── generators │ └── apple_auth │ ├── apple_auth_controller │ ├── apple_auth_controller_generator.rb │ └── templates │ │ └── apple_auth_controller.rb │ └── config │ ├── config_generator.rb │ └── templates │ └── config.rb └── spec ├── base_spec.rb ├── config_spec.rb ├── generators ├── apple_auth_controller_spec.rb └── config_generator_spec.rb ├── helpers ├── jwt_conditions_spec.rb └── jwt_server_conditions_spec.rb ├── server_identity_spec.rb ├── spec_helper.rb ├── token_spec.rb └── user_identity_spec.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Bug report: 11 | * **Expected Behavior**: 12 | * **Actual Behavior**: 13 | * **Steps to Reproduce**: 14 | 1. 15 | 2. 16 | 3. 17 | 18 | * **Version of the repo**: 19 | * **Ruby and Rails Version**: 20 | * **Rails Stacktrace**: this can be found in the `log/development.log` or `log/test.log`, if this is applicable. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE]' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | 7 | 8 | ### Other Information 9 | 10 | 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["master", "main"] 6 | 7 | push: 8 | branches: ["master", "main"] 9 | 10 | concurrency: 11 | group: ${{ github.ref }}-CI 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | linters: 16 | runs-on: ubuntu-latest 17 | name: linter/ruby 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: 3.0 26 | bundler-cache: true 27 | 28 | - name: Run linters 29 | run: bundle exec rake code_analysis 30 | test: 31 | runs-on: ubuntu-latest 32 | container: ${{ matrix.ruby }} 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | include: 37 | - ruby: ruby:3.0 38 | coverage: true 39 | - ruby: ruby:3.1 40 | - ruby: ruby:3.2 41 | - ruby: ruby:3.3 42 | name: test/ruby ${{ matrix.ruby }} 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | - name: Bundle install 47 | run: bundle install -j$(nproc) --retry 3 48 | - name: Run tests 49 | run: bundle exec rspec 50 | timeout-minutes: 1 51 | - name: Test & publish code coverage 52 | uses: paambaati/codeclimate-action@v5.0.0 53 | if: matrix.coverage && github.ref == 'refs/heads/master' 54 | env: 55 | CC_TEST_REPORTER_ID: cb01575b98b3b80848a3bc292ca6145860871470e5d0669453030d36578f9115 56 | with: 57 | coverageCommand: bundle exec rspec 58 | coverageLocations: ${{ github.workspace }}/coverage/coverage.json:simplecov 59 | debug: true 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | .byebug_history 13 | .DS_Store 14 | 15 | Gemfile.lock 16 | 17 | Dockerfile 18 | docker-compose.yml 19 | 20 | # File created by the generators tests 21 | spec/generators/tmp/ 22 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | detectors: 2 | Attribute: 3 | enabled: false 4 | exclude: [] 5 | BooleanParameter: 6 | enabled: true 7 | exclude: [] 8 | ClassVariable: 9 | enabled: false 10 | exclude: [] 11 | ControlParameter: 12 | enabled: true 13 | exclude: [] 14 | DataClump: 15 | enabled: true 16 | exclude: [] 17 | max_copies: 2 18 | min_clump_size: 2 19 | DuplicateMethodCall: 20 | enabled: true 21 | exclude: [] 22 | max_calls: 1 23 | allow_calls: [] 24 | FeatureEnvy: 25 | enabled: true 26 | exclude: [] 27 | InstanceVariableAssumption: 28 | enabled: false 29 | IrresponsibleModule: 30 | enabled: false 31 | exclude: [] 32 | LongParameterList: 33 | enabled: true 34 | exclude: [] 35 | max_params: 4 36 | overrides: 37 | initialize: 38 | max_params: 5 39 | LongYieldList: 40 | enabled: true 41 | exclude: [] 42 | max_params: 3 43 | ManualDispatch: 44 | enabled: true 45 | exclude: [] 46 | MissingSafeMethod: 47 | enabled: false 48 | exclude: [] 49 | ModuleInitialize: 50 | enabled: true 51 | exclude: [] 52 | NestedIterators: 53 | enabled: true 54 | exclude: [] 55 | max_allowed_nesting: 2 56 | NilCheck: 57 | enabled: false 58 | exclude: [] 59 | RepeatedConditional: 60 | enabled: true 61 | exclude: [] 62 | max_ifs: 3 63 | SubclassedFromCoreClass: 64 | enabled: true 65 | exclude: [] 66 | TooManyConstants: 67 | enabled: true 68 | exclude: [] 69 | max_constants: 5 70 | TooManyInstanceVariables: 71 | enabled: true 72 | exclude: [] 73 | max_instance_variables: 9 74 | TooManyMethods: 75 | enabled: true 76 | exclude: [] 77 | max_methods: 25 78 | TooManyStatements: 79 | enabled: true 80 | exclude: 81 | - initialize 82 | max_statements: 12 83 | UncommunicativeMethodName: 84 | enabled: true 85 | exclude: [] 86 | reject: 87 | - "/^[a-z]$/" 88 | - "/[0-9]$/" 89 | - "/[A-Z]/" 90 | accept: [] 91 | UncommunicativeModuleName: 92 | enabled: true 93 | exclude: [] 94 | reject: 95 | - "/^.$/" 96 | - "/[0-9]$/" 97 | accept: 98 | - Inline::C 99 | - "/V[0-9]/" 100 | UncommunicativeParameterName: 101 | enabled: true 102 | exclude: [] 103 | reject: 104 | - "/^.$/" 105 | - "/[0-9]$/" 106 | - "/[A-Z]/" 107 | accept: [] 108 | UncommunicativeVariableName: 109 | enabled: true 110 | exclude: [] 111 | reject: 112 | - "/^.$/" 113 | - "/[0-9]$/" 114 | - "/[A-Z]/" 115 | accept: 116 | - _ 117 | - e 118 | UnusedParameters: 119 | enabled: true 120 | exclude: [] 121 | UnusedPrivateMethod: 122 | enabled: false 123 | UtilityFunction: 124 | enabled: false 125 | 126 | exclude_paths: 127 | - config 128 | - lib/generators/apple_auth/apple_auth_controller/templates 129 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.0 3 | SuggestExtensions: false 4 | NewCops: disable 5 | Exclude: 6 | - lib/generators/apple_auth/apple_auth_controller/templates/** 7 | 8 | Style/Documentation: 9 | Enabled: false 10 | 11 | Layout/SpaceBeforeFirstArg: 12 | Exclude: 13 | 14 | Lint/AmbiguousBlockAssociation: 15 | Exclude: 16 | - spec/**/* 17 | 18 | Metrics/AbcSize: 19 | # The ABC size is a calculated magnitude, so this number can be an Integer or 20 | # a Float. 21 | Max: 15 22 | 23 | Metrics/BlockLength: 24 | CountComments: false 25 | Max: 25 26 | Exclude: 27 | - "*.gemspec" 28 | - config/**/* 29 | - spec/**/* 30 | AllowedMethods: 31 | - class_methods 32 | 33 | Metrics/BlockNesting: 34 | Max: 4 35 | 36 | Metrics/ClassLength: 37 | CountComments: false 38 | Max: 200 39 | 40 | # Avoid complex methods. 41 | Metrics/CyclomaticComplexity: 42 | Max: 7 43 | 44 | Metrics/MethodLength: 45 | CountComments: false 46 | Max: 24 47 | 48 | Metrics/ModuleLength: 49 | CountComments: false 50 | Max: 200 51 | 52 | Layout/LineLength: 53 | Max: 100 54 | # To make it possible to copy or click on URIs in the code, we allow lines 55 | # containing a URI to be longer than Max. 56 | AllowURI: true 57 | URISchemes: 58 | - http 59 | - https 60 | 61 | Metrics/ParameterLists: 62 | Max: 5 63 | CountKeywordArgs: true 64 | 65 | Metrics/PerceivedComplexity: 66 | Max: 12 67 | 68 | Style/FrozenStringLiteralComment: 69 | Enabled: true 70 | 71 | Style/ModuleFunction: 72 | Enabled: false 73 | 74 | Style/RescueModifier: 75 | Exclude: 76 | - spec/**/* 77 | 78 | Naming/PredicateName: 79 | Enabled: false 80 | 81 | Style/HashEachMethods: 82 | Enabled: true 83 | 84 | Style/HashTransformKeys: 85 | Enabled: true 86 | 87 | Style/HashTransformValues: 88 | Enabled: true 89 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | # These owners will be the default owners for everything in 3 | # the repo. Unless a later match takes precedence, 4 | # @global-owner1 and @global-owner2 will be requested for 5 | # review when someone opens a pull request. 6 | 7 | * @fedeagripa @vitogit @mrubi-rootstrap @santib @juanmanuelramallo 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at federicogagripa@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing ## 2 | 3 | You can contribute to this repo if you have an issue, found a bug or think there's some functionality required that would add value to the gem. To do so, please check if there's not already an [issue](https://github.com/rootstrap/apple_auth/issues) for that, if you find there's not, create a new one with as much detail as possible. 4 | 5 | If you want to contribute with code as well, please follow the next steps: 6 | 7 | 1. Read, understand and agree to our [code of conduct](https://github.com/rootstrap/apple_auth/blob/master/CODE_OF_CONDUCT.md) 8 | 2. [Fork the repo](https://help.github.com/articles/about-forks/) 9 | 3. Clone the project into your machine: 10 | `$ git clone git@github.com:rootstrap/apple_auth.git` 11 | 4. Access the repo: 12 | `$ cd apple_auth` 13 | 5. Create your feature/bugfix branch: 14 | `$ git checkout -b your_new_feature` 15 | or 16 | `$ git checkout -b fix/your_fix` in case of a bug fix 17 | (if your PR is to address an existing issue, it would be good to name the branch after the issue, for example: if you are trying to solve issue 182, then a good idea for the branch name would be `182_your_new_feature`) 18 | 6. Write tests for your changes (feature/bug) 19 | 7. Code your (feature/bugfix) 20 | 8. Run the code analysis tool by doing: 21 | `$ rake code_analysis` 22 | 9. Run the tests: 23 | `$ bundle exec rspec` 24 | All tests must pass. If all tests (both code analysis and rspec) do pass, then you are ready to go to the next step: 25 | 10. Commit your changes: 26 | `$ git commit -m 'Your feature or bugfix title'` 27 | 11. Push to the branch `$ git push origin your_new_feature` 28 | 12. Create a new [pull request](https://help.github.com/articles/creating-a-pull-request/) 29 | 30 | Some helpful guides that will help you know how we work: 31 | 1. [Code review](https://github.com/rootstrap/tech-guides/tree/master/code-review) 32 | 2. [GIT workflow](https://github.com/rootstrap/tech-guides/tree/master/git) 33 | 3. [Ruby style guide](https://github.com/rootstrap/tech-guides/tree/master/ruby) 34 | 4. [Rails style guide](https://github.com/rootstrap/tech-guides/blob/master/ruby/rails.md) 35 | 5. [RSpec style guide](https://github.com/rootstrap/tech-guides/blob/master/ruby/rspec/README.md) 36 | 37 | For more information or guides like the ones mentioned above, please check our [tech guides](https://github.com/rootstrap/tech-guides). Keep in mind that the more you know about these guides, the easier it will be for your code to get approved and merged. 38 | 39 | Note: You can push as many commits as you want when working on a pull request, we just ask that they are descriptive and tell a story. Try to open a pull request with just one commit but if you think you need to divide what you did into more commits to convey what you are trying to do go for it. 40 | 41 | Thank you very much for your time and for considering helping in this project. 42 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in apple_sign_in.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 fedeagripa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AppleAuth 2 | 3 | [![Gem Version](https://badge.fury.io/rb/apple_auth.svg)](https://badge.fury.io/rb/apple_auth) 4 | ![CI](https://github.com/rootstrap/apple_auth/actions/workflows/ci.yml/badge.svg?branch=master) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/78453501221a76e3806e/maintainability)](https://codeclimate.com/github/rootstrap/apple_sign_in/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/78453501221a76e3806e/test_coverage)](https://codeclimate.com/github/rootstrap/apple_sign_in/test_coverage) 7 | 8 | ## Installation 9 | 10 | Add this line to your Gemfile: 11 | 12 | ```ruby 13 | gem 'apple_auth' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle install 19 | 20 | Or install it yourself: 21 | 22 | $ gem install apple_auth 23 | 24 | --- 25 | 26 | After installing the gem, you need to run this generator. 27 | 28 | $ rails g apple_auth:config 29 | 30 | This will generate a new initializer: `apple_auth.rb` with the following default configuration: 31 | 32 | ```ruby 33 | AppleAuth.configure do |config| 34 | # config.apple_client_id = 35 | # config.apple_private_key = 36 | # config.apple_key_id = 37 | # config.apple_team_id = 38 | # config.redirect_uri = 39 | end 40 | ``` 41 | 42 | Set your different credentials in the file by uncommenting the lines and adding your keys. 43 | 44 | --- 45 | 46 | ## Usage 47 | 48 | Here's an example of how to configure the gem: 49 | 50 | ```ruby 51 | AppleAuth.configure do |config| 52 | config.apple_client_id = 'com.yourapp...' 53 | config.apple_private_key = "-----BEGIN PRIVATE KEY-----\nMIGTAgEA....\n-----END PRIVATE KEY-----" 54 | config.apple_key_id = 'RTZ...' 55 | config.apple_team_id = 'WNU...' 56 | config.redirect_uri = 'https://localhost:3000' 57 | end 58 | ``` 59 | 60 | We strongly recommend to use environment variables for these values. 61 | 62 | ### Apple sign-in workflow: 63 | 64 | ![alt text](https://docs-assets.developer.apple.com/published/360d59b776/rendered2x-1592224731.png) 65 | 66 | For more information, check the [Apple oficial documentation](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api). 67 | 68 | ### Validate JWT token and get user information: 69 | 70 | ```ruby 71 | # with a valid JWT 72 | user_id = '000343.1d22d2937c7a4e56806dfb802b06c430...' 73 | valid_jwt_token = 'eyJraWQiOiI4NkQ4OEtmIiwiYWxnIjoiUlMyNTYifQ.eyJpc...' 74 | AppleAuth::UserIdentity.new(user_id, valid_jwt_token).validate! 75 | >> { exp: 1595279622, email: "user@example.com", email_verified: true , ...} 76 | 77 | # with an invalid JWT 78 | invalid_jwt_token = 'eyJraWQiOiI4NkQsd4OEtmIiwiYWxnIjoiUlMyNTYifQ.edsyJpc...' 79 | AppleAuth::UserIdentity.new(user_id, invalid_jwt_token).validate! 80 | >> Traceback (most recent call last):.. 81 | >> ... 82 | >> AppleAuth::Conditions::JWTValidationError 83 | ``` 84 | 85 | ### Verify user identity and get access and refresh tokens: 86 | 87 | ```ruby 88 | code = 'cfb77c21ecd444390a2c214cd33decdfb.0.mr...' 89 | AppleAuth::Token.new(code).authenticate! 90 | >> { access_token: "a7058d...", expires_at: 1595894672, refresh_token: "r8f1ce..." } 91 | ``` 92 | 93 | ### Handle server to server notifications 94 | 95 | from the request parameter :payload 96 | 97 | ```ruby 98 | # with a valid JWT 99 | params[:payload] = "eyJraWQiOiJZ......" 100 | AppleAuth::ServerIdentity.new(params[:payload]).validate! 101 | >> {iss: "https://appleid.apple.com", exp: 1632224024, iat: 1632137624, jti: "yctpp1ZHaGCzaNB9PWB4DA",...} 102 | 103 | # with an invalid JWT 104 | params[:payload] = "asdasdasdasd......" 105 | AppleAuth::ServerIdentity.new(params[:payload]).validate! 106 | >> JWT::VerificationError: Signature verification raised 107 | ``` 108 | 109 | Implementation in a controller would look like this: 110 | 111 | ```ruby 112 | class Hooks::AuthController < ApplicationController 113 | 114 | skip_before_action :verify_authenticity_token 115 | 116 | # https://developer.apple.com/documentation/sign_in_with_apple/processing_changes_for_sign_in_with_apple_accounts 117 | # NOTE: The Apple documentation states the events attribute as an array but is in fact a stringified json object 118 | def apple 119 | # will raise an error when the signature is invalid 120 | payload = AppleAuth::ServerIdentity.new(params[:payload]).validate! 121 | event = JSON.parse(payload[:events]).symbolize_keys 122 | uid = event["sub"] 123 | user = User.find_by!(provider: 'apple', uid: uid) 124 | 125 | case event[:type] 126 | when "email-enabled", "email-disabled" 127 | # Here we should update the user with the relay state 128 | when "consent-revoked", "account-delete" 129 | user.destroy! 130 | else 131 | throw event 132 | end 133 | render plain: "200 OK", status: :ok 134 | end 135 | end 136 | ``` 137 | 138 | ## Using with Devise 139 | 140 | If you are using devise_token_auth gem, run this generator. 141 | 142 | $ rails g apple_sign_in:apple_auth_controller [scope] 143 | 144 | In the scope you need to write your path from controllers to your existent devise controllers. 145 | An example `$ rails g apple_auth:apple_auth_controller api/v1/` 146 | This will generate a new controller: `controllers/api/v1/apple_auth_controller.rb`. 147 | 148 | You should configure the route, you can wrap it in the devise_scope block like: 149 | 150 | ``` 151 | devise_scope :user do 152 | resource :user, only: %i[update show] do 153 | controller :apple_auth do 154 | post :apple_auth, on: :collection, to: 'apple_auth#create' 155 | end 156 | end 157 | end 158 | ``` 159 | 160 | ## Demo 161 | 162 | You can find a full implementation of this gem in [this demo](https://github.com/rootstrap/apple-sign-in-rails). 163 | 164 | ## Development 165 | 166 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 167 | 168 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 169 | 170 | ## Contributing 171 | 172 | Bug reports and pull requests are welcome on GitHub at https://github.com/rootstrap/apple_auth/issues. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/rootstrap/apple_auth/blob/master/CODE_OF_CONDUCT.md). 173 | 174 | ## License 175 | 176 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 177 | 178 | ## Code of Conduct 179 | 180 | Everyone interacting in the AppleAuth project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/rootstrap/apple_auth/blob/master/CODE_OF_CONDUCT.md). 181 | 182 | ## Credits 183 | 184 | apple_auth gem is maintained by [Rootstrap](http://www.rootstrap.com) with the help of our 185 | [contributors](https://github.com/rootstrap/apple_auth/contributors). 186 | 187 | [](http://www.rootstrap.com) 188 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | task :code_analysis do 4 | sh 'bundle exec rubocop lib spec' 5 | sh 'bundle exec reek lib' 6 | end 7 | -------------------------------------------------------------------------------- /apple_auth.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/apple_auth/base/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'apple_auth' 7 | spec.version = AppleAuth::Base::VERSION 8 | spec.authors = ['Timothy Peraza, Antonieta Alvarez, Martín Morón'] 9 | spec.email = ['timothy@rootstrap.com, antonieta.alvarez@rootstrap.com, martin.jaime@rootstrap.com'] 10 | 11 | spec.summary = 'Integration with Apple Sign In and Devise for backend. Validate and Verify user token.' 12 | spec.homepage = 'https://github.com/rootstrap/apple_auth' 13 | spec.license = 'MIT' 14 | spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0') 15 | 16 | spec.metadata['homepage_uri'] = spec.homepage 17 | spec.metadata['source_code_uri'] = 'https://github.com/rootstrap/apple_auth' 18 | spec.metadata['changelog_uri'] = 'https://github.com/rootstrap/apple_auth' 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 24 | end 25 | spec.bindir = 'exe' 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ['lib'] 28 | 29 | # Production dependencies 30 | spec.add_dependency 'jwt', '~> 2.2' 31 | spec.add_dependency 'oauth2', '~> 1.4' 32 | 33 | # Development dependencies 34 | spec.add_development_dependency 'generator_spec', '~> 0.9.4' 35 | spec.add_development_dependency 'byebug', '~> 11.1' 36 | spec.add_development_dependency 'railties', '~> 6.0' 37 | spec.add_development_dependency 'rake', '~> 13.0' 38 | spec.add_development_dependency 'reek', '~> 6.0' 39 | spec.add_development_dependency 'rspec', '~> 3.9' 40 | spec.add_development_dependency 'rubocop', '~> 1.66' 41 | spec.add_development_dependency 'parser', '~> 3.3.0' 42 | spec.add_development_dependency 'simplecov', '~> 0.17.1' 43 | spec.add_development_dependency 'webmock', '~> 3.8' 44 | end 45 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'apple_auth' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/apple_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Rails Core 4 | require 'active_support/core_ext/hash' 5 | require 'rails/generators' 6 | 7 | # Ruby Core 8 | require 'base64' 9 | require 'json' 10 | require 'net/http' 11 | 12 | # Gems 13 | require 'jwt' 14 | require 'oauth2' 15 | 16 | # Files 17 | require 'apple_auth/config' 18 | require 'apple_auth/helpers/conditions/jwt_validation_error' 19 | 20 | require 'apple_auth/helpers/conditions/aud_condition' 21 | require 'apple_auth/helpers/conditions/exp_condition' 22 | require 'apple_auth/helpers/conditions/iat_condition' 23 | require 'apple_auth/helpers/conditions/iss_condition' 24 | require 'apple_auth/helpers/jwt_conditions' 25 | require 'apple_auth/helpers/jwt_decoder' 26 | require 'apple_auth/helpers/jwt_server_conditions' 27 | 28 | require 'apple_auth/server_identity' 29 | require 'apple_auth/user_identity' 30 | require 'apple_auth/token' 31 | 32 | require 'generators/apple_auth/config/config_generator' 33 | require 'generators/apple_auth/apple_auth_controller/apple_auth_controller_generator' 34 | -------------------------------------------------------------------------------- /lib/apple_auth/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'apple_auth/base/version' 4 | 5 | module AppleAuth 6 | module Base 7 | class Error < StandardError; end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/apple_auth/base/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Base 5 | VERSION = '1.1.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/apple_auth/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | class << self 5 | def configure 6 | yield config 7 | end 8 | 9 | def reset_configuration 10 | @config = Config.new 11 | end 12 | 13 | def config 14 | @config ||= Config.new 15 | end 16 | end 17 | 18 | class Config 19 | attr_accessor :apple_client_id, :apple_private_key, :apple_key_id, 20 | :apple_team_id, :redirect_uri 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/conditions/aud_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Conditions 5 | class AudCondition 6 | def initialize(jwt) 7 | @aud = jwt['aud'] 8 | end 9 | 10 | def validate! 11 | return true if @aud == AppleAuth.config.apple_client_id 12 | 13 | raise JWTValidationError, 'jwt_aud is different to apple_client_id' 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/conditions/exp_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Conditions 5 | class ExpCondition 6 | def initialize(jwt) 7 | @exp = jwt['exp'].to_i 8 | end 9 | 10 | def validate! 11 | return true if @exp > Time.now.to_i 12 | 13 | raise JWTValidationError, 'Expired jwt_exp' 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/conditions/iat_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Conditions 5 | class IatCondition 6 | def initialize(jwt) 7 | @iat = jwt['iat'].to_i 8 | end 9 | 10 | def validate! 11 | return true if @iat <= Time.now.to_i 12 | 13 | raise JWTValidationError, 'jwt_iat is greater than now' 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/conditions/iss_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Conditions 5 | class IssCondition 6 | APPLE_ISS = 'https://appleid.apple.com' 7 | 8 | def initialize(jwt) 9 | @iss = jwt['iss'] 10 | end 11 | 12 | def validate! 13 | return true if @iss.include?(APPLE_ISS) 14 | 15 | raise JWTValidationError, 'jwt_iss is different to apple_iss' 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/conditions/jwt_validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Conditions 5 | class JWTValidationError < StandardError; end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/jwt_conditions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module AppleAuth 4 | class JWTConditions 5 | include Conditions 6 | 7 | CONDITIONS = [ 8 | AudCondition, 9 | ExpCondition, 10 | IatCondition, 11 | IssCondition 12 | ].freeze 13 | 14 | attr_reader :user_identity, :decoded_jwt 15 | 16 | def initialize(user_identity, decoded_jwt) 17 | @user_identity = user_identity 18 | @decoded_jwt = decoded_jwt 19 | end 20 | 21 | def validate! 22 | JWT::ClaimsValidator.new(decoded_jwt).validate! && validate_sub! && jwt_conditions_validate! 23 | rescue JWT::InvalidPayload => e 24 | raise JWTValidationError, e.message 25 | end 26 | 27 | private 28 | 29 | def validate_sub! 30 | return true if user_identity && user_identity == decoded_jwt['sub'] 31 | 32 | raise JWTValidationError, 'Not valid Sub' 33 | end 34 | 35 | def jwt_conditions_validate! 36 | conditions_results = CONDITIONS.map do |condition| 37 | condition.new(decoded_jwt).validate! 38 | end 39 | conditions_results.all? { |value| value == true } 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/jwt_decoder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module AppleAuth 4 | class JWTDecoder 5 | APPLE_KEY_URL = 'https://appleid.apple.com/auth/keys'.freeze 6 | 7 | attr_reader :jwt 8 | 9 | def initialize(jwt) 10 | @jwt = jwt 11 | end 12 | 13 | def call 14 | decoded.first 15 | end 16 | 17 | private 18 | 19 | def decoded 20 | key_hash = apple_key_hash(jwt) 21 | apple_jwk = JWT::JWK.import(key_hash) 22 | JWT.decode(jwt, apple_jwk.public_key, true, algorithm: key_hash['alg']) 23 | end 24 | 25 | def apple_key_hash(jwt) 26 | response = Net::HTTP.get(URI.parse(APPLE_KEY_URL)) 27 | certificate = JSON.parse(response) 28 | matching_key = certificate['keys'].select { |key| key['kid'] == jwt_kid(jwt) } 29 | ActiveSupport::HashWithIndifferentAccess.new(matching_key.first) 30 | end 31 | 32 | def jwt_kid(jwt) 33 | header = JSON.parse(Base64.decode64(jwt.split('.').first)) 34 | header['kid'] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/apple_auth/helpers/jwt_server_conditions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module AppleAuth 4 | class JWTServerConditions 5 | include Conditions 6 | 7 | CONDITIONS = [ 8 | AudCondition, 9 | IatCondition, 10 | IssCondition 11 | ].freeze 12 | 13 | attr_reader :decoded_jwt 14 | 15 | def initialize(decoded_jwt) 16 | @decoded_jwt = decoded_jwt 17 | end 18 | 19 | def validate! 20 | JWT::ClaimsValidator.new(decoded_jwt).validate! && jwt_conditions_validate! 21 | rescue JWT::InvalidPayload => e 22 | raise JWTValidationError, e.message 23 | end 24 | 25 | private 26 | 27 | def jwt_conditions_validate! 28 | conditions_results = CONDITIONS.map do |condition| 29 | condition.new(decoded_jwt).validate! 30 | end 31 | conditions_results.all? { |value| value == true } 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/apple_auth/server_identity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | class ServerIdentity 5 | attr_reader :jwt 6 | 7 | def initialize(jwt) 8 | @jwt = jwt 9 | end 10 | 11 | def validate! 12 | token_data = JWTDecoder.new(jwt).call 13 | 14 | JWTServerConditions.new(token_data).validate! 15 | 16 | token_data.symbolize_keys 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/apple_auth/token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | class Token 5 | APPLE_AUD = 'https://appleid.apple.com' 6 | APPLE_CONFIG = AppleAuth.config 7 | APPLE_CODE_TYPE = 'authorization_code' 8 | APPLE_ALG = 'ES256' 9 | 10 | def initialize(code) 11 | @code = code 12 | end 13 | 14 | # :reek:FeatureEnvy 15 | def authenticate! 16 | access_token = apple_access_token 17 | access_token.refresh! if access_token.expired? 18 | 19 | reponse_hash(access_token) 20 | end 21 | 22 | private 23 | 24 | attr_reader :code 25 | 26 | def apple_token_params 27 | { 28 | client_id: APPLE_CONFIG.apple_team_id, 29 | client_secret: client_secret_from_jwt, 30 | grant_type: APPLE_CODE_TYPE, 31 | redirect_uri: APPLE_CONFIG.redirect_uri, 32 | code: code 33 | } 34 | end 35 | 36 | def client_secret_from_jwt 37 | JWT.encode(claims, gen_private_key, APPLE_ALG, claims_headers) 38 | end 39 | 40 | def claims 41 | time_now = Time.now.to_i 42 | { 43 | iss: APPLE_CONFIG.apple_team_id, 44 | iat: time_now, 45 | exp: time_now + 10.minutes.to_i, 46 | aud: APPLE_AUD, 47 | sub: APPLE_CONFIG.apple_client_id 48 | } 49 | end 50 | 51 | def claims_headers 52 | { 53 | alg: APPLE_ALG, 54 | kid: AppleAuth.config.apple_key_id 55 | } 56 | end 57 | 58 | def request_header 59 | { 60 | 'Content-Type': 'application/x-www-form-urlencoded' 61 | } 62 | end 63 | 64 | def gen_private_key 65 | key = AppleAuth.config.apple_private_key 66 | key = OpenSSL::PKey::EC.new(key) unless key.instance_of?(OpenSSL::PKey::EC) 67 | key 68 | end 69 | 70 | def client_urls 71 | { 72 | site: APPLE_AUD, 73 | authorize_url: '/auth/authorize', 74 | token_url: '/auth/token' 75 | } 76 | end 77 | 78 | def reponse_hash(access_token) 79 | token_hash = { access_token: access_token.token } 80 | 81 | expires = access_token.expires? 82 | if expires 83 | token_hash[:expires_at] = access_token.expires_at 84 | refresh_token = access_token.refresh_token 85 | token_hash[:refresh_token] = refresh_token if refresh_token 86 | end 87 | 88 | token_hash 89 | end 90 | 91 | def apple_access_token 92 | client = ::OAuth2::Client.new(APPLE_CONFIG.apple_client_id, 93 | client_secret_from_jwt, 94 | client_urls) 95 | client.auth_code.get_token(code, { redirect_uri: APPLE_CONFIG.redirect_uri }, {}) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/apple_auth/user_identity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | class UserIdentity 5 | attr_reader :user_identity, :jwt 6 | 7 | def initialize(user_identity, jwt) 8 | @user_identity = user_identity 9 | @jwt = jwt 10 | end 11 | 12 | def validate! 13 | token_data = JWTDecoder.new(jwt).call 14 | 15 | JWTConditions.new(user_identity, token_data).validate! 16 | 17 | token_data.symbolize_keys 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/apple_auth/apple_auth_controller/apple_auth_controller_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Generators 5 | class AppleAuthControllerGenerator < Rails::Generators::Base 6 | source_root File.expand_path('templates', __dir__) 7 | argument :scope, required: false, default: '' 8 | 9 | def copy_apple_auth_controller_file 10 | @scope_prefix = scope.blank? ? '' : scope.camelize 11 | template 'apple_auth_controller.rb', 12 | "app/controllers/#{scope}apple_auth_controller.rb" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/apple_auth/apple_auth_controller/templates/apple_auth_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= @scope_prefix %>AppleAuthController < DeviseTokenAuth::SessionsController 4 | protect_from_forgery with: :null_session 5 | skip_before_action :verify_authenticity_token 6 | before_action :skip_session_storage 7 | before_action :check_json_request 8 | 9 | def create 10 | apple_params = apple_validate 11 | @resource = sign_in_with_apple(apple_params) 12 | custom_sign_in 13 | rescue AppleAuth::Conditions::JWTValidationError, OAuth2::Error, JWT::ExpiredSignature => e 14 | render_error(:bad_request, e.message) 15 | end 16 | 17 | private 18 | 19 | def apple_validate 20 | data = AppleAuth::UserIdentity.new( 21 | apple_sign_in_params[:user_identity], 22 | apple_sign_in_params[:jwt] 23 | ).validate! 24 | AppleAuth::Token.new(apple_sign_in_params[:code]).authenticate! 25 | 26 | data.slice(:email) 27 | end 28 | 29 | def custom_sign_in 30 | sign_in(:api_v1_user, @resource) 31 | new_auth_header = @resource.create_new_auth_token 32 | response.headers.merge!(new_auth_header) 33 | render_create_success 34 | end 35 | 36 | def sign_in_with_apple(user_params) 37 | user = User.where(provider: 'apple', uid: user_params[:email]).first_or_create! 38 | user.password = Devise.friendly_token[0, 20] 39 | user.assign_attributes user_params.except('id') 40 | user 41 | end 42 | 43 | def apple_sign_in_params 44 | params.permit(:user_identity, :jwt, :code) 45 | end 46 | 47 | def check_json_request 48 | return if request_content_type&.match?(/json/) 49 | 50 | render json: { error: I18n.t('api.errors.invalid_content_type') }, status: :not_acceptable 51 | end 52 | 53 | def render_create_success 54 | render json: { user: resource_data } 55 | end 56 | 57 | def render_error(status, message, _data = nil) 58 | response = { 59 | error: message 60 | } 61 | render json: response, status: status 62 | end 63 | 64 | def skip_session_storage 65 | # Devise stores the cookie by default, so in api requests, it is disabled 66 | # http://stackoverflow.com/a/12205114/2394842 67 | request.session_options[:skip] = true 68 | end 69 | 70 | def request_content_type 71 | request.content_type 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/generators/apple_auth/config/config_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AppleAuth 4 | module Generators 5 | class ConfigGenerator < Rails::Generators::Base 6 | source_root File.expand_path('templates', __dir__) 7 | 8 | def copy_config_file 9 | copy_file 'config.rb', 'config/initializers/apple_auth.rb' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/apple_auth/config/templates/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | AppleAuth.configure do |config| 4 | # config.apple_client_id = 5 | # config.apple_private_key = 6 | # config.apple_key_id = 7 | # config.apple_team_id = 8 | # config.redirect_uri = 9 | end 10 | -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe AppleAuth do 4 | it 'has a version number' do 5 | expect(AppleAuth::Base::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth do 6 | let(:apple_client_id) { 'mocked_client_id' } 7 | let(:apple_private_key) { 'mocked_private_key' } 8 | let(:apple_key_id) { 'mocked_key_id' } 9 | let(:apple_team_id) { 'mocked_team_id' } 10 | let(:redirect_uri) { 'https://example.com/redirect_uri' } 11 | 12 | def configure_apple_variables 13 | AppleAuth.configure do |config| 14 | config.apple_client_id = apple_client_id 15 | config.apple_private_key = apple_private_key 16 | config.apple_key_id = apple_key_id 17 | config.apple_team_id = apple_team_id 18 | config.redirect_uri = redirect_uri 19 | end 20 | end 21 | 22 | describe '.configure' do 23 | it 'adds the configuration to the module' do 24 | configure_apple_variables 25 | 26 | expect(AppleAuth.config.apple_client_id).to eq apple_client_id 27 | expect(AppleAuth.config.apple_private_key).to eq apple_private_key 28 | expect(AppleAuth.config.apple_key_id).to eq apple_key_id 29 | expect(AppleAuth.config.apple_team_id).to eq apple_team_id 30 | expect(AppleAuth.config.redirect_uri).to eq redirect_uri 31 | end 32 | end 33 | 34 | describe '.reset_configuration' do 35 | before do 36 | configure_apple_variables 37 | end 38 | 39 | it 'resets all the configuration of the module' do 40 | AppleAuth.reset_configuration 41 | 42 | expect(AppleAuth.config.apple_client_id).not_to be_present 43 | expect(AppleAuth.config.apple_private_key).not_to be_present 44 | expect(AppleAuth.config.apple_key_id).not_to be_present 45 | expect(AppleAuth.config.apple_team_id).not_to be_present 46 | expect(AppleAuth.config.redirect_uri).not_to be_present 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/generators/apple_auth_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe AppleAuth::Generators::AppleAuthControllerGenerator, type: :generator do 4 | destination File.expand_path('tmp', __dir__) 5 | 6 | before do 7 | prepare_destination 8 | end 9 | 10 | it 'creates the controller file' do 11 | run_generator 12 | 13 | expect(File).to exist('spec/generators/tmp/app/controllers/apple_auth_controller.rb') 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/generators/config_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth::Generators::ConfigGenerator, type: :generator do 6 | destination File.expand_path('tmp', __dir__) 7 | 8 | it 'creates the config file' do 9 | run_generator 10 | 11 | expect(File).to exist('spec/generators/tmp/config/initializers/apple_auth.rb') 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/helpers/jwt_conditions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth::JWTConditions do 6 | let(:user_identity) { '1234.5678.910' } 7 | let(:jwt_sub) { user_identity } 8 | let(:jwt_iss) { 'https://appleid.apple.com' } 9 | let(:jwt_aud) { 'com.apple_auth' } 10 | let(:jwt_iat) { Time.now.to_i } 11 | let(:jwt_exp) { (jwt_iat + 5.minutes).to_i } 12 | let(:jwt) do 13 | { 14 | iss: jwt_iss, 15 | aud: jwt_aud, 16 | exp: jwt_exp, 17 | iat: jwt_iat, 18 | sub: jwt_sub, 19 | email: 'timmy@test.com', 20 | email_verified: 'true', 21 | is_private_email: 'false' 22 | } 23 | end 24 | 25 | let(:decoded_jwt) { ActiveSupport::HashWithIndifferentAccess.new(jwt) } 26 | 27 | before do 28 | AppleAuth.config.apple_client_id = 'com.apple_auth' 29 | end 30 | 31 | subject(:jwt_conditions_helper) { described_class.new(user_identity, decoded_jwt) } 32 | 33 | context '#valid?' do 34 | context 'when decoded jwt attributes are valid and user_identity is valid' do 35 | it 'returns true' do 36 | expect(jwt_conditions_helper.validate!).to eq(true) 37 | end 38 | end 39 | 40 | context 'when jwt has incorrect type attributes' do 41 | context 'when exp is not a integer' do 42 | let(:jwt_exp) { Time.now + 5.minutes } 43 | 44 | it 'raises an exception' do 45 | expect { jwt_conditions_helper.validate! }.to raise_error( 46 | AppleAuth::Conditions::JWTValidationError 47 | ) 48 | end 49 | end 50 | 51 | context 'when iat is not a integer' do 52 | let(:jwt_iat) { Time.now } 53 | 54 | it 'raises an exception' do 55 | expect { jwt_conditions_helper.validate! }.to raise_error( 56 | AppleAuth::Conditions::JWTValidationError 57 | ) 58 | end 59 | end 60 | end 61 | 62 | context 'when jwt sub is different to user_identity' do 63 | let(:jwt_sub) { '1234.5678.911' } 64 | 65 | it 'raises an exception' do 66 | expect { jwt_conditions_helper.validate! }.to raise_error( 67 | AppleAuth::Conditions::JWTValidationError 68 | ) 69 | end 70 | end 71 | 72 | context 'when jwt aud is different to apple_client_id' do 73 | let(:jwt_aud) { 'net.apple_auth' } 74 | 75 | it 'raises an exception' do 76 | expect { jwt_conditions_helper.validate! }.to raise_error( 77 | AppleAuth::Conditions::JWTValidationError, 'jwt_aud is different to apple_client_id' 78 | ) 79 | end 80 | end 81 | 82 | context 'when jwt_iss is different to apple_iss' do 83 | let(:jwt_iss) { 'https://appleid.apple.net' } 84 | 85 | it 'raises an exception' do 86 | expect { jwt_conditions_helper.validate! }.to raise_error( 87 | AppleAuth::Conditions::JWTValidationError, 'jwt_iss is different to apple_iss' 88 | ) 89 | end 90 | end 91 | 92 | context 'when jwt_exp is leasser than now' do 93 | let(:jwt_exp) { Time.now.to_i } 94 | 95 | it 'raises an exception' do 96 | expect { jwt_conditions_helper.validate! }.to raise_error( 97 | AppleAuth::Conditions::JWTValidationError, 'Expired jwt_exp' 98 | ) 99 | end 100 | end 101 | 102 | context 'when jwt_iat is greater than now' do 103 | let(:jwt_iat) { (Time.now + 5.minutes).to_i } 104 | 105 | it 'raises an exception' do 106 | expect { jwt_conditions_helper.validate! }.to raise_error( 107 | AppleAuth::Conditions::JWTValidationError, 'jwt_iat is greater than now' 108 | ) 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/helpers/jwt_server_conditions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth::JWTServerConditions do 6 | let(:jwt_sub) { '820417.faa325acbc78e1be1668ba852d492d8a.0219' } 7 | let(:jwt_iss) { 'https://appleid.apple.com' } 8 | let(:jwt_aud) { 'com.apple_auth' } 9 | let(:jwt_iat) { Time.now.to_i } 10 | let(:jwt) do 11 | { 12 | iss: jwt_iss, 13 | aud: jwt_aud, 14 | iat: jwt_iat, 15 | events: '{ 16 | "type": "email-enabled", 17 | "sub": "820417.faa325acbc78e1be1668ba852d492d8a.0219", 18 | "email": "ep9ks2tnph@privaterelay.appleid.com", 19 | "is_private_email": "true", 20 | "event_time": 1508184845 21 | }' 22 | } 23 | end 24 | 25 | let(:decoded_jwt) { ActiveSupport::HashWithIndifferentAccess.new(jwt) } 26 | 27 | before do 28 | AppleAuth.config.apple_client_id = 'com.apple_auth' 29 | end 30 | 31 | subject(:jwt_conditions_helper) { described_class.new(decoded_jwt) } 32 | 33 | context '#valid?' do 34 | context 'when decoded jwt attributes are valid' do 35 | it 'returns true' do 36 | expect(jwt_conditions_helper.validate!).to eq(true) 37 | end 38 | end 39 | 40 | context 'when jwt has incorrect type attributes' do 41 | context 'when iat is not a integer' do 42 | let(:jwt_iat) { Time.now } 43 | 44 | it 'raises an exception' do 45 | expect { jwt_conditions_helper.validate! }.to raise_error( 46 | AppleAuth::Conditions::JWTValidationError 47 | ) 48 | end 49 | end 50 | end 51 | 52 | context 'when jwt_aud is different to apple_client_id' do 53 | let(:jwt_aud) { 'net.apple_auth' } 54 | 55 | it 'raises an exception' do 56 | expect { jwt_conditions_helper.validate! }.to raise_error( 57 | AppleAuth::Conditions::JWTValidationError, 'jwt_aud is different to apple_client_id' 58 | ) 59 | end 60 | end 61 | 62 | context 'when jwt_iss is different to apple_iss' do 63 | let(:jwt_iss) { 'https://appleid.apple.net' } 64 | 65 | it 'raises an exception' do 66 | expect { jwt_conditions_helper.validate! }.to raise_error( 67 | AppleAuth::Conditions::JWTValidationError, 'jwt_iss is different to apple_iss' 68 | ) 69 | end 70 | end 71 | 72 | context 'when jwt_iat is greater than now' do 73 | let(:jwt_iat) { (Time.now + 5.minutes).to_i } 74 | 75 | it 'raises an exception' do 76 | expect { jwt_conditions_helper.validate! }.to raise_error( 77 | AppleAuth::Conditions::JWTValidationError, 'jwt_iat is greater than now' 78 | ) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/server_identity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth::ServerIdentity do 6 | let(:jwt_iss) { 'https://appleid.apple.com' } 7 | let(:jwt_aud) { 'com.apple_sign_in' } 8 | let(:jwt_iat) { Time.now.to_i } 9 | let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } 10 | let(:jwk) { JWT::JWK.new(private_key) } 11 | let(:jwt) do 12 | { 13 | iss: jwt_iss, 14 | aud: jwt_aud, 15 | iat: jwt_iat, 16 | events: '{ 17 | "type": "email-enabled", 18 | "sub": "820417.faa325acbc78e1be1668ba852d492d8a.0219", 19 | "email": "ep9ks2tnph@privaterelay.appleid.com", 20 | "is_private_email": "true", 21 | "event_time": 1508184845 22 | }' 23 | } 24 | end 25 | 26 | let(:signed_jwt) { JWT.encode(jwt, jwk.keypair, 'RS256', kid: jwk.kid) } 27 | let(:exported_private_key) { JWT::JWK::RSA.new(private_key).export.merge({ alg: 'RS256' }) } 28 | let(:apple_body) { [exported_private_key] } 29 | 30 | before do 31 | stub_request(:get, 'https://appleid.apple.com/auth/keys') 32 | .to_return( 33 | body: { 34 | keys: apple_body 35 | }.to_json, 36 | status: 200, 37 | headers: { 'Content-Type': 'application/json' } 38 | ) 39 | AppleAuth.config.apple_client_id = jwt_aud 40 | end 41 | 42 | subject(:server_identity_service) { described_class.new(signed_jwt) } 43 | 44 | context '#valid?' do 45 | context 'when the parameters of the initilizer are correct' do 46 | it 'returns the validated JWT attributes' do 47 | expect(server_identity_service.validate!).to eq(jwt) 48 | end 49 | 50 | context 'when there are more than one private keys' do 51 | let(:private_key_two) { OpenSSL::PKey::RSA.generate(2048) } 52 | let(:exported_private_key_two) do 53 | JWT::JWK::RSA.new(private_key).export.merge({ alg: 'RS256' }) 54 | end 55 | 56 | it 'returns the validated JWT attributes' do 57 | expect(server_identity_service.validate!).to eq(jwt) 58 | end 59 | end 60 | end 61 | 62 | context 'when the parameters of the initilizer are not correct' do 63 | let(:jwt) do 64 | { 65 | iss: 'https://not-an-appleid.com', 66 | aud: jwt_aud, 67 | iat: jwt_iat 68 | } 69 | end 70 | 71 | it 'raises an exception' do 72 | expect { server_identity_service.validate! }.to raise_error( 73 | AppleAuth::Conditions::JWTValidationError 74 | ) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec' 4 | require 'action_controller/railtie' 5 | require 'generator_spec' 6 | require 'bundler/setup' 7 | require 'webmock/rspec' 8 | 9 | require 'simplecov' 10 | SimpleCov.start do 11 | add_filter '/spec/' 12 | end 13 | 14 | require './lib/apple_auth' 15 | 16 | require 'apple_auth/base' 17 | 18 | RSpec.configure do |config| 19 | # Enable flags like --only-failures and --next-failure 20 | config.example_status_persistence_file_path = '.rspec_status' 21 | 22 | # Disable RSpec exposing methods globally on `Module` and `main` 23 | config.disable_monkey_patching! 24 | 25 | config.expect_with :rspec do |c| 26 | c.syntax = :expect 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | 5 | RSpec.describe AppleAuth::Token do 6 | subject(:token_service) { described_class.new(code) } 7 | 8 | context '#authenticate!' do 9 | context 'when parameters are valid' do 10 | let(:code) { 'valid_code' } 11 | 12 | before do 13 | AppleAuth.config.apple_client_id = 'client_id' 14 | AppleAuth.config.apple_private_key = OpenSSL::PKey::EC.generate('prime256v1') 15 | AppleAuth.config.apple_key_id = 'apple_kid' 16 | AppleAuth.config.apple_team_id = 'team_id' 17 | AppleAuth.config.redirect_uri = 'www.example.com' 18 | end 19 | 20 | context 'when the acces token is not expired' do 21 | before do 22 | mocked_data = OpenStruct.new(token: '1234', 'expired?': false) 23 | allow(token_service).to receive(:apple_access_token).and_return(mocked_data) 24 | end 25 | 26 | it 'returns a hash with the corresponding access_token and expired value' do 27 | expect(token_service.authenticate!).to include( 28 | { 29 | access_token: '1234' 30 | } 31 | ) 32 | end 33 | end 34 | 35 | context 'when the acces token is expired' do 36 | before do 37 | mocked_data = OpenStruct.new('expired?': true, 38 | 'expires?': true, 39 | refresh_token: '4321', 40 | expires_at: 1_594_667_034) 41 | allow(token_service).to receive(:apple_access_token).and_return(mocked_data) 42 | end 43 | 44 | it 'returns a hash with the corresponding access_token and expired value' do 45 | expect(token_service.authenticate!).to include( 46 | { 47 | refresh_token: '4321', 48 | expires_at: 1_594_667_034, 49 | access_token: nil 50 | } 51 | ) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/user_identity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe AppleAuth::UserIdentity do 6 | let(:jwt_sub) { user_identity } 7 | let(:jwt_iss) { 'https://appleid.apple.com' } 8 | let(:jwt_aud) { 'com.apple_sign_in' } 9 | let(:jwt_iat) { Time.now } 10 | let(:jwt_exp) { jwt_iat + 5.minutes } 11 | let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } 12 | let(:jwk) { JWT::JWK.new(private_key) } 13 | let(:jwt) do 14 | { 15 | iss: jwt_iss, 16 | aud: jwt_aud, 17 | exp: jwt_exp.to_i, 18 | iat: jwt_iat.to_i, 19 | sub: jwt_sub, 20 | email: 'timmy@test.com', 21 | email_verified: 'true', 22 | is_private_email: 'false' 23 | } 24 | end 25 | let(:signed_jwt) { JWT.encode(jwt, jwk.keypair, 'RS256', kid: jwk.kid) } 26 | let(:exported_private_key) { JWT::JWK::RSA.new(private_key).export.merge({ alg: 'RS256' }) } 27 | let(:apple_body) { [exported_private_key] } 28 | 29 | before do 30 | stub_request(:get, 'https://appleid.apple.com/auth/keys') 31 | .to_return( 32 | body: { 33 | keys: apple_body 34 | }.to_json, 35 | status: 200, 36 | headers: { 'Content-Type': 'application/json' } 37 | ) 38 | AppleAuth.config.apple_client_id = jwt_aud 39 | end 40 | 41 | subject(:user_identity_service) { described_class.new(uid, signed_jwt) } 42 | 43 | context '#valid?' do 44 | context 'when the parameters of the initilizer are correct' do 45 | let(:user_identity) { '1234.5678.910' } 46 | let(:uid) { user_identity } 47 | 48 | it 'returns the validated JWT attributes' do 49 | expect(user_identity_service.validate!).to eq(jwt) 50 | end 51 | 52 | context 'when there are more than one private keys' do 53 | let(:private_key_two) { OpenSSL::PKey::RSA.generate(2048) } 54 | let(:exported_private_key_two) do 55 | JWT::JWK::RSA.new(private_key).export.merge({ alg: 'RS256' }) 56 | end 57 | 58 | it 'returns the validated JWT attributes' do 59 | expect(user_identity_service.validate!).to eq(jwt) 60 | end 61 | end 62 | end 63 | 64 | context 'when the parameters of the initilizer are not correct' do 65 | let(:user_identity) { '1234.5678.910' } 66 | let(:uid) { '1234.5678.911' } 67 | 68 | it 'raises an exception' do 69 | expect { user_identity_service.validate! }.to raise_error( 70 | AppleAuth::Conditions::JWTValidationError 71 | ) 72 | end 73 | end 74 | end 75 | end 76 | --------------------------------------------------------------------------------