-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for JWT-formatted tokens (#11)
* 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
1 parent
0fe35cf
commit cdb77d9
Showing
10 changed files
with
396 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.