Skip to content

Commit d71d10d

Browse files
authored
Merge pull request #29 from kdgm/validate-server-to-server-callbacks
Handle server to server notifications
2 parents d1aa92d + b54df64 commit d71d10d

File tree

11 files changed

+310
-37
lines changed

11 files changed

+310
-37
lines changed

README.md

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# AppleAuth
22

3-
[![CI](https://api.travis-ci.org/rootstrap/apple_auth.svg?branch=master)](https://travis-ci.org/github/rootstrap/apple_auth)
3+
[![CI](https://api.travis-ci.com/rootstrap/apple_auth.svg?branch=master)](https://travis-ci.com/github/rootstrap/apple_auth)
44
[![Maintainability](https://api.codeclimate.com/v1/badges/78453501221a76e3806e/maintainability)](https://codeclimate.com/github/rootstrap/apple_sign_in/maintainability)
55
[![Test Coverage](https://api.codeclimate.com/v1/badges/78453501221a76e3806e/test_coverage)](https://codeclimate.com/github/rootstrap/apple_sign_in/test_coverage)
66

@@ -56,13 +56,13 @@ end
5656

5757
We strongly recommend to use environment variables for these values.
5858

59-
Apple sign-in workflow:
59+
### Apple sign-in workflow:
6060

6161
![alt text](https://docs-assets.developer.apple.com/published/360d59b776/rendered2x-1592224731.png)
6262

6363
For more information, check the [Apple oficial documentation](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api).
6464

65-
Validate JWT token and get user information:
65+
### Validate JWT token and get user information:
6666

6767
```ruby
6868
# with a valid JWT
@@ -79,14 +79,59 @@ AppleAuth::UserIdentity.new(user_id, invalid_jwt_token).validate!
7979
>> AppleAuth::Conditions::JWTValidationError
8080
```
8181

82-
Verify user identity and get access and refresh tokens:
82+
### Verify user identity and get access and refresh tokens:
8383

8484
```ruby
8585
code = 'cfb77c21ecd444390a2c214cd33decdfb.0.mr...'
8686
AppleAuth::Token.new(code).authenticate!
8787
>> { access_token: "a7058d...", expires_at: 1595894672, refresh_token: "r8f1ce..." }
8888
```
8989

90+
### Handle server to server notifications
91+
92+
from the request parameter :payload
93+
94+
```ruby
95+
# with a valid JWT
96+
params[:payload] = "eyJraWQiOiJZ......"
97+
AppleAuth::ServerIdentity.new(params[:payload]).validate!
98+
>> {iss: "https://appleid.apple.com", exp: 1632224024, iat: 1632137624, jti: "yctpp1ZHaGCzaNB9PWB4DA",...}
99+
100+
# with an invalid JWT
101+
params[:payload] = "asdasdasdasd......"
102+
AppleAuth::ServerIdentity.new(params[:payload]).validate!
103+
>> JWT::VerificationError: Signature verification raised
104+
```
105+
106+
Implementation in a controller would look like this:
107+
108+
```ruby
109+
class Hooks::AuthController < ApplicationController
110+
111+
skip_before_action :verify_authenticity_token
112+
113+
# https://developer.apple.com/documentation/sign_in_with_apple/processing_changes_for_sign_in_with_apple_accounts
114+
# NOTE: The Apple documentation states the events attribute as an array but is in fact a stringified json object
115+
def apple
116+
# will raise an error when the signature is invalid
117+
payload = AppleAuth::ServerIdentity.new(params[:payload]).validate!
118+
event = JSON.parse(payload[:events]).symbolize_keys
119+
uid = event["sub"]
120+
user = User.find_by!(provider: 'apple', uid: uid)
121+
122+
case event[:type]
123+
when "email-enabled", "email-disabled"
124+
# Here we should update the user with the relay state
125+
when "consent-revoked", "account-delete"
126+
user.destroy!
127+
else
128+
throw event
129+
end
130+
render plain: "200 OK", status: :ok
131+
end
132+
end
133+
```
134+
90135
## Using with Devise
91136

92137
If you are using devise_token_auth gem, run this generator.

lib/apple_auth.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
require 'apple_auth/helpers/conditions/iat_condition'
2323
require 'apple_auth/helpers/conditions/iss_condition'
2424
require 'apple_auth/helpers/jwt_conditions'
25+
require 'apple_auth/helpers/jwt_decoder'
26+
require 'apple_auth/helpers/jwt_server_conditions'
2527

28+
require 'apple_auth/server_identity'
2629
require 'apple_auth/user_identity'
2730
require 'apple_auth/token'
2831

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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

lib/apple_auth/server_identity.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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

lib/apple_auth/user_identity.rb

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
module AppleAuth
44
class UserIdentity
5-
APPLE_KEY_URL = 'https://appleid.apple.com/auth/keys'
6-
75
attr_reader :user_identity, :jwt
86

97
def initialize(user_identity, jwt)
@@ -12,31 +10,11 @@ def initialize(user_identity, jwt)
1210
end
1311

1412
def validate!
15-
token_data = decoded_jwt
13+
token_data = JWTDecoder.new(jwt).call
1614

1715
JWTConditions.new(user_identity, token_data).validate!
1816

1917
token_data.symbolize_keys
2018
end
21-
22-
private
23-
24-
def decoded_jwt
25-
key_hash = apple_key_hash
26-
apple_jwk = JWT::JWK.import(key_hash)
27-
JWT.decode(jwt, apple_jwk.public_key, true, algorithm: key_hash['alg']).first
28-
end
29-
30-
def apple_key_hash
31-
response = Net::HTTP.get(URI.parse(APPLE_KEY_URL))
32-
certificate = JSON.parse(response)
33-
matching_key = certificate['keys'].select { |key| key['kid'] == jwt_kid }
34-
ActiveSupport::HashWithIndifferentAccess.new(matching_key.first)
35-
end
36-
37-
def jwt_kid
38-
header = JSON.parse(Base64.decode64(jwt.split('.').first))
39-
header['kid']
40-
end
4119
end
4220
end

spec/helpers/jwt_conditions_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
end
4949
end
5050

51-
context 'when exp is not a integer' do
51+
context 'when iat is not a integer' do
5252
let(:jwt_iat) { Time.now }
5353

5454
it 'raises an exception' do
@@ -59,7 +59,7 @@
5959
end
6060
end
6161

62-
context 'when jwt iss is different to user_identity' do
62+
context 'when jwt sub is different to user_identity' do
6363
let(:jwt_sub) { '1234.5678.911' }
6464

6565
it 'raises an exception' do
@@ -69,7 +69,7 @@
6969
end
7070
end
7171

72-
context 'when jwt_aud is different to apple_client_id' do
72+
context 'when jwt aud is different to apple_client_id' do
7373
let(:jwt_aud) { 'net.apple_auth' }
7474

7575
it 'raises an exception' do
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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

0 commit comments

Comments
 (0)