Parse and validate DPoP Tokens
See the epic (Allow users to require demonstrated proof of po... (&14383)) for context, pre-work, and other related issues.
This issue is to provide logic that can:
- Validate that DPoP token (a signed JWT) is well formed according to the spec
- E.g. each part of the token has the correct number of parts, the signing algorithm is supported, the timestamps are recent enough, that a valid key ID is present, etc etc
- Validate that a DPoP token was correctly signed with one of a user's valid and current signing keys according to the spec. Specifically:
- the
kidreferences a valid, current signing key for the provided user - the
athclaim is a signature that corresponds to the private key referenced inkid
- the
Not in this issue:
- Using this logic in the request flow of any API requests. See Sender constraining personal access tokens (#425130 - closed)
- Extracting a DPoP Token from the headers of an http request
- Documenting the feature. See Allow users to require demonstrated proof of po... (&14383).
Class boiler plate code / interface
This is a team effort between @nmalcolm and @ameyadarshan. Nick is responsible for providing a skeleton / boilerplate / framework for this work, and Ameya will implement it (with Nick's help and support as needed.
Code
# This class encapsulates the logic to
module Auth
class DpopAuthenticationService < BaseService
def initialize(current_user, personal_access_token_plaintext, request)
# Raise errors if these are missing
# Raise an error unless DpopToken.enabled_for_user?(current_user)
# Raise if the PAT does not belong to the user or is inactive
# Extract the raw DPoP token from the request header, and check there's only one header
@dpop_token = extract_dpop_from_request(request)
end
def execute!
# Create a DpopToken
# Call unsafe_well_formed?. It will raise if there's an error.
# Call valid_for_user_and_pat?(user, pat). It will raise if there's an error.
true
end
private
attr_reader :dpop_token
def extract_dpop_from_request(request)
# get the header value
# raise if multiple, otherwise return it
end
end
end
# Demonstrated Proof of Possession (DPoP) is a mechanism to tie a user's
# Personal Access Token (PAT) to one of their signing keys.
#
# A DPoP Token is a signed JSON Web Token, and this class implements
# the logic to ensure a provided DPoP Token is well-formed and
# cryptographically signed.
#
# In this class:
# - well-formed is defined as being a syntatically correct JWT that is
# using supported values (e.g. supported algorithms).
# - valid is defined as being cryptographically signed with a user's
# active signing key. A user may have many active signing key's; it
# must match one.
#
# All checks MUST raise an error if they fail:
# - JWT errors will use JWT error classes & message (don't catch & re-raise)
# - DPoP specific errors will use a DPoP error class
class DpopToken
SUPPORTED_JWS_ALGORITHMS = ['RS256']
SUPPORTED_TYPES = ['dpop+jwt']
SUPPORTED_KEY_TYPES = ['RSA']
SUPPORTED_PROOF_KEY_ID_HASHING_ALGORITHMS = ['SHA256']
KID_DELIMETER = ':'
def self.dpop_enabled_for_user?(user)
# Check the user is a human, not a bot, etc
# Check the FF is enabled for the user
# Check the user has enabled DPoP
# Return true / false
end
def initialize(dpop_token)
@dpop_token = dpop_token
raise ArgumentError unless dpop_token.present
@payload, @header = JWT.decode(
dpop_token,
nil, # we do not pass a key here as we are not checking the signature
false, # we are not verifying the signature or claims
)
end
# We prepend this method with `unsafe` to signal to developers that
# well-formed DOES NOT equal valid.
def unsafe_well_formed?
well_formed_header?
well_formed_body?
end
def valid_for_user_and_pat?(user, plaintext)
call valid_for_user?(user)
valid_access_token_hash_for?(plaintext)
# NOT FOR AMEYA: we don't expose those publicly to avoid developers
# calling one without calling the other. It makes it a bit harder
# to test since we can only access this method in rspec
end
# The methods below are public but (currently) only used by DpopToken
# itself. It's easier to test
# A DPoP Token has a header containing metadata
def well_formed_header?
# All comparisons should be case-sensitive, using secure comparison
# See https://www.rfc-editor.org/rfc/rfc7515#section-4.1.1
# Check header[typ]
# Check header[alg] is one of SUPPORTED_JWS_ALGORITHMS.
# NOTE FOR AMEYA: Can we skip this if JWT.decode will validate it
# in .valid_for_user?
# Check the format of header[kid] (ALGORITHM DELIMITER b64(HASH))
# Check kid_algorithm is supported
# Check that the hash can be decoded from base64
end
def kid_fingerprint
# Get a fingerprint from the header. We already checked the
# algorithm in well_formed_header?
end
# A DPoP Token has a body containing additional metadata and the
# signature
def well_formed_body?
# NOTE FOR AMEYA: This might not be required? JWT.decode will
# validate iat, exp, and ath in .valid_for_user?
end
# NOTE TO AMEYA: It kinda feels like this maybe shouldn't exist
# in this class?
def signing_key_for_user!(user)
# Get a signing key from the user based on the fingerprint. Raise if you can't.
# Use the `kid_fingerprint` method to keep logic small and testable.
# Validate the signing key uses a supported algorithm.
end
def kid_algorithm
# Get the kid algorithm
end
# A user's public key is prefixed with its algorithm. This method
# should return that algorithm, or raise if it can't.
def algorithm_from_users_public_key(key)
end
private
attr_reader :dpop_token, :header, :payload
# Check that the DPoP is signed with a SSH key belonging to the user
def valid_for_user?(user)
raise unless self.dpop_enabled_for_user?(user)
raise unless unsafe_well_formed?
# Decode the JSON token again, this time with the key,
# the expected algorthm, verifying all the timestamps, etc
# Overwrites the attrs, in case .decode returns a different result
# when verify is true.
@payload, @header = JWT.decode(
dpop_token,
signing_key_for_user!(user),
true,
{
required_claims: %w[exp ath iat],
algorithm: SUPPORTED_JWS_ALGORITHMS,
verify_iat: true,
# ...
}
)
end
# Check that the DPoP contains a hash of the PAT being used.
# Users can have multiple PATs, so we still need to check that
# they created this DPoP for this particular PAT.
def valid_access_token_hash_for?(plaintext)
expected_hash = Base64.urlsafe_encode64(
Digest::SHA256.digest(plaintext),
padding: false
)
raise unless secure_compare(payload['ath'], expected_hash)
end
end
Edited by Nick Malcolm