Skip to content

Commit

Permalink
Add support for JWT-formatted tokens (#11)
Browse files Browse the repository at this point in the history
* Add support for JWT-formatted tokens

See issue #10 for details

* Accept pre-parsed JWT tokens in Slosilo::token_signer

* Add some docstrings re JWT methods

In particular it indicates some useful claims and explains that
it's the caller's responsibility to verify claims this library
doesn't understand.

Closes #12

* Remove typ: 'JWT' header field

It's optional and not really useful.
Can always be provided by the client if required.
  • Loading branch information
dividedmind authored and jvanderhoof committed Oct 12, 2017
1 parent 0fe35cf commit cdb77d9
Show file tree
Hide file tree
Showing 10 changed files with 396 additions and 5 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# v2.1.0

* Add support for JWT-formatted tokens, with arbitrary expiration.

# v2.0.1

* Fixes a bug that occurs when signing tokens containing Unicode data
Expand Down
1 change: 1 addition & 0 deletions lib/slosilo.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "slosilo/jwt"
require "slosilo/version"
require "slosilo/keystore"
require "slosilo/symmetric"
Expand Down
3 changes: 3 additions & 0 deletions lib/slosilo/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ def initialize msg = "can't store a private key in a plaintext storage"
super
end
end

class TokenValidationError < Error
end
end
end
122 changes: 122 additions & 0 deletions lib/slosilo/jwt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
require 'json'

module Slosilo
# A JWT-formatted Slosilo token.
# @note This is not intended to be a general-purpose JWT implementation.
class JWT
# Create a new unsigned token with the given claims.
# @param claims [#to_h] claims to embed in this token.
def initialize claims = {}
@claims = JSONHash[claims]
end

# Parse a token in compact representation
def self.parse_compact raw
load *raw.split('.', 3).map(&Base64.method(:urlsafe_decode64))
end

# Parse a token in JSON representation.
# @note only single signature is currently supported.
def self.parse_json raw
raw = JSON.load raw unless raw.respond_to? :to_h
parts = raw.to_h.values_at(*%w(protected payload signature))
fail ArgumentError, "input not a complete JWT" unless parts.all?
load *parts.map(&Base64.method(:urlsafe_decode64))
end

# Add a signature.
# @note currently only a single signature is handled;
# the token will be frozen after this operation.
def add_signature header, &sign
@claims = canonicalize_claims.freeze
@header = JSONHash[header].freeze
@signature = sign[string_to_sign].freeze
freeze
end

def string_to_sign
[header, claims].map(&method(:encode)).join '.'
end

# Returns the JSON serialization of this JWT.
def to_json *a
{
protected: encode(header),
payload: encode(claims),
signature: encode(signature)
}.to_json *a
end

# Returns the compact serialization of this JWT.
def to_s
[header, claims, signature].map(&method(:encode)).join('.')
end

attr_accessor :claims, :header, :signature

private

# Create a JWT token object from existing header, payload, and signature strings.
# @param header [#to_s] URLbase64-encoded representation of the protected header
# @param payload [#to_s] URLbase64-encoded representation of the token payload
# @param signature [#to_s] URLbase64-encoded representation of the signature
def self.load header, payload, signature
self.new(JSONHash.load payload).tap do |token|
token.header = JSONHash.load header
token.signature = signature.to_s.freeze
token.freeze
end
end

def canonicalize_claims
claims[:iat] = Time.now unless claims.include? :iat
claims[:iat] = claims[:iat].to_time.to_i
claims[:exp] = claims[:exp].to_time.to_i if claims.include? :exp
JSONHash[claims.to_a]
end

# Convenience method to make the above code clearer.
# Converts to string and urlbase64-encodes.
def encode s
Base64.urlsafe_encode64 s.to_s
end

# a hash with a possibly frozen JSON stringification
class JSONHash < Hash
def to_s
@repr || to_json
end

def freeze
@repr = to_json.freeze
super
end

def self.load raw
self[JSON.load raw.to_s].tap do |h|
h.send :repr=, raw
end
end

private

def repr= raw
@repr = raw.freeze
freeze
end
end
end

# Try to convert by detecting token representation and parsing
def self.JWT raw
if raw.is_a? JWT
raw
elsif raw.respond_to?(:to_h) || raw =~ /\A\s*\{/
JWT.parse_json raw
else
JWT.parse_compact raw
end
rescue
raise ArgumentError, "invalid value for JWT(): #{raw.inspect}"
end
end
70 changes: 69 additions & 1 deletion lib/slosilo/key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
require 'base64'
require 'time'

require 'slosilo/errors'

module Slosilo
class Key
def initialize raw_key = nil
Expand Down Expand Up @@ -71,14 +73,80 @@ def signed_token data
token["key"] = fingerprint
token
end

JWT_ALGORITHM = 'conjur.org/slosilo/v2'.freeze

# Issue a JWT with the given claims.
# `iat` (issued at) claim is automatically added.
# Other interesting claims you can give are:
# - `sub` - token subject, for example a user name;
# - `exp` - expiration time (absolute);
# - `cidr` (Conjur extension) - array of CIDR masks that are accepted to
# make requests that bear this token
def issue_jwt claims
token = Slosilo::JWT.new claims
token.add_signature \
alg: JWT_ALGORITHM,
kid: fingerprint,
&method(:sign)
token.freeze
end

DEFAULT_EXPIRATION = 8 * 60

def token_valid? token, expiry = 8 * 60
def token_valid? token, expiry = DEFAULT_EXPIRATION
return jwt_valid? token if token.respond_to? :header
token = token.clone
expected_key = token.delete "key"
return false if (expected_key and (expected_key != fingerprint))
signature = Base64::urlsafe_decode64(token.delete "signature")
(Time.parse(token["timestamp"]) + expiry > Time.now) && verify_signature(token, signature)
end

# Validate a JWT.
#
# Convenience method calling #validate_jwt and returning false if an
# exception is raised.
#
# @param token [JWT] pre-parsed token to verify
# @return [Boolean]
def jwt_valid? token
validate_jwt token
true
rescue
false
end

# Validate a JWT.
#
# First checks whether algorithm is 'conjur.org/slosilo/v2' and the key id
# matches this key's fingerprint. Then verifies if the token is not expired,
# as indicated by the `exp` claim; in its absence tokens are assumed to
# expire in `iat` + 8 minutes.
#
# If those checks pass, finally the signature is verified.
#
# @raises TokenValidationError if any of the checks fail.
#
# @note It's the responsibility of the caller to examine other claims
# included in the token; consideration needs to be given to handling
# unrecognized claims.
#
# @param token [JWT] pre-parsed token to verify
def validate_jwt token
def err msg
raise Error::TokenValidationError, msg, caller
end

header = token.header
err 'unrecognized algorithm' unless header['alg'] == JWT_ALGORITHM
err 'mismatched key' if (kid = header['kid']) && kid != fingerprint
iat = Time.at token.claims['iat'] || err('unknown issuing time')
exp = Time.at token.claims['exp'] || (iat + DEFAULT_EXPIRATION)
err 'token expired' if exp <= Time.now
err 'invalid signature' unless verify_signature token.string_to_sign, token.signature
true
end

def sign_string value
salt = shake_salt
Expand Down
15 changes: 13 additions & 2 deletions lib/slosilo/keystore.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,26 @@ def token_valid? token
keystore.any? { |k| k.token_valid? token }
end

# Looks up the signer by public key fingerprint and checks the validity
# of the signature. If the token is JWT, exp and/or iat claims are also
# verified; the caller is responsible for validating any other claims.
def token_signer token
key, id = keystore.get_by_fingerprint token['key']
begin
# see if maybe it's a JWT
token = JWT token
fingerprint = token.header['kid']
rescue ArgumentError
fingerprint = token['key']
end

key, id = keystore.get_by_fingerprint fingerprint
if key && key.token_valid?(token)
return id
else
return nil
end
end

attr_accessor :adapter

private
Expand Down
2 changes: 1 addition & 1 deletion lib/slosilo/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Slosilo
VERSION = "2.0.1"
VERSION = "2.1.0"
end
104 changes: 104 additions & 0 deletions spec/jwt_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
require 'spec_helper'

# (Mostly) integration tests for JWT token format
describe Slosilo::Key do
include_context "with example key"

describe '#issue_jwt' do
it 'issues an JWT token with given claims' do
allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time }

tok = key.issue_jwt sub: 'host/example', cidr: %w(fec0::/64)

expect(tok).to be_frozen

expect(tok.header).to eq \
alg: 'conjur.org/slosilo/v2',
kid: key_fingerprint,
typ: 'JWT'
expect(tok.claims).to eq \
iat: 1401938552,
sub: 'host/example',
cidr: ['fec0::/64']

expect(key.verify_signature tok.string_to_sign, tok.signature).to be_truthy
end
end
end

describe Slosilo::JWT do
context "with a signed token" do
let(:signature) { 'very signed, such alg' }
subject(:token) { Slosilo::JWT.new test: "token" }
before do
allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time }
token.add_signature(alg: 'test-sig') { signature }
end

it 'allows conversion to JSON representation with #to_json' do
json = JSON.load token.to_json
expect(JSON.load Base64.urlsafe_decode64 json['protected']).to eq \
'alg' => 'test-sig', 'typ' => 'JWT'
expect(JSON.load Base64.urlsafe_decode64 json['payload']).to eq \
'iat' => 1401938552, 'test' => 'token'
expect(Base64.urlsafe_decode64 json['signature']).to eq signature
end

it 'allows conversion to compact representation with #to_s' do
h, c, s = token.to_s.split '.'
expect(JSON.load Base64.urlsafe_decode64 h).to eq \
'alg' => 'test-sig', 'typ' => 'JWT'
expect(JSON.load Base64.urlsafe_decode64 c).to eq \
'iat' => 1401938552, 'test' => 'token'
expect(Base64.urlsafe_decode64 s).to eq signature
end
end

describe '#to_json' do
it "passes any parameters" do
token = Slosilo::JWT.new
allow(token).to receive_messages \
header: :header,
claims: :claims,
signature: :signature
expect_any_instance_of(Hash).to receive(:to_json).with :testing
expect(token.to_json :testing)
end
end

describe '()' do
include_context "with example key"

it 'understands both serializations' do
[COMPACT_TOKEN, JSON_TOKEN].each do |token|
token = Slosilo::JWT token
expect(token.header).to eq \
'typ' => 'JWT',
'alg' => 'conjur.org/slosilo/v2',
'kid' => key_fingerprint
expect(token.claims).to eq \
'sub' => 'host/example',
'iat' => 1401938552,
'exp' => 1401938552 + 60*60,
'cidr' => ['fec0::/64']

expect(key.verify_signature token.string_to_sign, token.signature).to be_truthy
end
end

it 'is a noop if already parsed' do
token = Slosilo::JWT COMPACT_TOKEN
expect(Slosilo::JWT token).to eq token
end

it 'raises ArgumentError on failure to convert' do
expect { Slosilo::JWT "foo bar" }.to raise_error ArgumentError
expect { Slosilo::JWT elite: 31337 }.to raise_error ArgumentError
expect { Slosilo::JWT "foo.bar.xyzzy" }.to raise_error ArgumentError
end
end

COMPACT_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiJkMjhlM2EzNDdlMzY4NDE2YjMxMjlhNDBjMWI4ODdmZSJ9.eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJpYXQiOjE0MDE5Mzg1NTIsImV4cCI6MTQwMTk0MjE1MiwiY2lkciI6WyJmZWMwOjovNjQiXX0=.cw9S8Oxu8BmvDgEotBlNiZoJNkAGDpvGIuhCCnG-nMq80dy0ECjC4xERYXHx3bcadEJ8jWfqDB90d7CGvJyepbMhC1hEdsb8xNWGkkqTvQOh33cnZiEJjjfbORpKnOpcc8QySmB9Eb_zKl5WaM6Sjm-uINJri3djuVo_n_S7I43YvKw7gbd5u2gVttgaWnqlnJoeXZnnmXSYH8_66Lr__BqO4tCedShQIf4gA0R_dljrzVSZtJsFTKvwuNOuCvBqO8dQkhp8vplOkKynDkdip-H2nDBQb9Y3bQ8K0NVtSJatBy-d1HvPHSwFZrH4K7P_J2OgJtw9GtckT43QasliXoibdk__Hyvy4HJIIM44rSm7JUuyZWl8e8svRqLujBP7".freeze

JSON_TOKEN = "{\"protected\":\"eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiJkMjhlM2EzNDdlMzY4NDE2YjMxMjlhNDBjMWI4ODdmZSJ9\",\"payload\":\"eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJpYXQiOjE0MDE5Mzg1NTIsImV4cCI6MTQwMTk0MjE1MiwiY2lkciI6WyJmZWMwOjovNjQiXX0=\",\"signature\":\"cw9S8Oxu8BmvDgEotBlNiZoJNkAGDpvGIuhCCnG-nMq80dy0ECjC4xERYXHx3bcadEJ8jWfqDB90d7CGvJyepbMhC1hEdsb8xNWGkkqTvQOh33cnZiEJjjfbORpKnOpcc8QySmB9Eb_zKl5WaM6Sjm-uINJri3djuVo_n_S7I43YvKw7gbd5u2gVttgaWnqlnJoeXZnnmXSYH8_66Lr__BqO4tCedShQIf4gA0R_dljrzVSZtJsFTKvwuNOuCvBqO8dQkhp8vplOkKynDkdip-H2nDBQb9Y3bQ8K0NVtSJatBy-d1HvPHSwFZrH4K7P_J2OgJtw9GtckT43QasliXoibdk__Hyvy4HJIIM44rSm7JUuyZWl8e8svRqLujBP7\"}".freeze
end
Loading

0 comments on commit cdb77d9

Please sign in to comment.