Commits

Michael Granger committed 7146baf

Adding initial SCS Cookie class implementation

  • Participants
  • Parent commits fd61434

Comments (0)

Files changed (7)

 # .rvm.gems generated gem export file. Note that any env variable settings will be missing. Append these after using a ';' field separator
-configurability -v1.1.0
-sequel -v3.35.0
-#strelka -v0.0.1
-#amalgalite -v1.1.2
 
+configurability -v1.2.0
+timecop -v0.5.1
+strelka -v0.0.1.pre.308
+
+# Settings
+projectDirectory     = "$CWD"
+windowTitle          = "${CWD/^.*\///} «$TM_DISPLAYNAME»"
+excludeInFileChooser = "{$exclude,.hg,pkg}"
+
+TM_RUBY                  = "/Users/mgranger/.rvm/bin/rvm-auto-ruby"
+
+TM_RSPEC_OPTS            = '-rrspec/core/formatters/webkit -Ilib:../Mongrel2/lib'
+TM_RSPEC_FORMATTER       = 'RSpec::Core::Formatters::WebKit'
+
+[ source.ruby ]
+disableIndentCorrections = true
+

lib/strelka/authprovider/authtoken.rb

 
 # AuthToken authentication provider for Strelka applications.
 #
-# This plugin provides cookie-based authentication using the "Secure Cookie Protocol"
-# described in:
+# This plugin provides cookie-based authentication using the "SCS: Secure Cookie 
+# Sessions for HTTP"
 #
-#   http://www.cse.msu.edu/~alexliu/publications/Cookie/cookie.pdf
+#   http://tools.ietf.org/html/draft-secure-cookie-session-protocol-04
 #
 # == Configuration
 #
 # The configuration for this provider is read from the 'auth' section of the config, and
 # may contain the following keys:
 #
-# [users]::   a Hash of username: SHA1+Base64'ed passwords
+# [realm]::         the 
+# [cookie_name]::   a Hash of username: SHA1+Base64'ed passwords
 #
 # An example:
 #
 #   --
 #   auth:
-#     realm: Acme Admin Console
-#     users:
-#       mgranger: "9d5lIumnMJXmVT/34QrMuyj+p0E="
-#       jblack: "1pAnQNSVtpL1z88QwXV4sG8NMP8="
-#       kmurgen: "MZj9+VhZ8C9+aJhmwp+kWBL76Vs="
+#     provider: authtoken
 #
-class Strelka::AuthProvider::Basic < Strelka::AuthProvider
+class Strelka::AuthProvider::AuthToken < Strelka::AuthProvider
 	extend Loggability,
 	       Configurability,
 	       Strelka::MethodUtilities
 	include Strelka::Constants
 
 	# Configurability API - set the section of the config
-	config_key :auth
+	config_key :authtoken
 
 
-	@users = nil
-	@realm = nil
+	# Default configuration
+	CONFIG_DEFAULTS = {
+		cookie_name:  'strelka-authtoken',
+		realm:        nil,
+		users:        [],
+	}.freeze
+
+
+	##
+	# The name of the cookie used for the authentication token
+	singleton_attr_accessor :cookie_name
+	@cookie_name = CONFIG_DEFAULTS[:cookie_name]
 
 	##
 	# The Hash of users and their SHA1+Base64'ed passwords
 	singleton_attr_accessor :users
+	@users = CONFIG_DEFAULTS[:users]
 
 	##
 	# The authentication realm
 	singleton_attr_accessor :realm
+	@realm = CONFIG_DEFAULTS[:realm]
 
 
 	### Configurability API -- configure the auth provider instance.
 	def self::configure( config=nil )
 		if config
-			self.log.debug "Configuring Basic authprovider: %p" % [ config ]
-			self.realm = config['realm'] if config['realm']
-			self.users = config['users'] if config['users']
+			self.log.debug "Configuring AuthToken authprovider: %p" % [ config ]
+			self.cookie_name  = config[:cookie_name]
+			self.realm        = config[:realm]
+			self.users        = config[:users]
 		else
-			self.realm = nil
-			self.users = {}
+			self.log.debug "Configuring AuthToken authprovider with default"
+			self.cookie_name  = CONFIG_DEFAULTS[:cookie_name]
+			self.realm        = CONFIG_DEFAULTS[:realm]
+			self.users        = CONFIG_DEFAULTS[:users]
 		end
 	end
 
 			self.log.warn "No users configured -- using an empty user list"
 			self.class.users = {}
 		end
+
 	end
 
 
 	# Check the authentication present in +request+ (if any) for validity, returning the
 	# authenticating user's name if authentication succeeds.
 	def authenticate( request )
-		authheader = request.header.authorization or
-			self.log_failure "No authorization header in the request."
+		Strelka::SCSCookie.rotate_keys
 
-		# Extract the credentials bit
-		base64_userpass = authheader[ /^\s*Basic\s+(\S+)$/i, 1 ] or
-			self.log_failure "Invalid Basic Authorization header (%p)" % [ authheader ]
+		if user = self.check_for_auth_cookie( request )
+			return user
+		else
+			finish_with( HTTP::AUTH_REQUIRED )
+		end
+	end
 
-		# Unpack the username and password
-		credentials = base64_userpass.unpack( 'm' ).first
-		self.log_failure "Malformed credentials %p" % [ credentials ] unless
-			credentials.index(':')
 
-		# Split the credentials, check for valid user
-		username, password = credentials.split( ':', 2 )
-		digest = self.class.users[ username ] or
-			self.log_failure "No such user %p." % [ username ]
+	### Extract credentials from the given request and validate them, either via a
+	### valid authentication token, or from request parameters.
+	def check_for_auth_cookie( request )
+		cookie = request.cookies[ self.class.cookie_name ] or
+			log_failure "No auth cookie: %s" % [ self.class.cookie_name ]
 
-		# Fail if the password's hash doesn't match
-		self.log_failure "Password mismatch." unless
-			digest == Digest::SHA1.base64digest( password )
+		scs_cookie = Strelka::SCSCookie.from_regular_cookie( cookie ) or
+			log_failure "Couldn't upgrade the %s cookie to SCS" % [ self.class.cookie_name]
 
-		# Success!
-		self.log.info "Authentication for %p succeeded." % [ username ]
-		return username
+		request.cookies[ self.class.cookie_name ] = scs_cookie
+		return scs_cookie.value
 	end
 
 
 	### Log a message at 'info' level and return false.
 	def log_failure( reason )
 		self.log.warn "Auth failure: %s" % [ reason ]
-		header = "Basic realm=%s" % [ self.class.realm ]
+		header = "AuthToken realm=%s" % [ self.class.realm ]
 		finish_with( HTTP::AUTH_REQUIRED, "Requires authentication.", www_authenticate: header )
 	end
 

lib/strelka/scscookie.rb

+# -*- ruby -*-
+# vim: set nosta noet ts=4 sw=4:
+
+require 'uri'
+require 'openssl'
+require 'zlib'
+
+require 'strelka' unless defined?( Strelka )
+require 'strelka/cookie'
+require 'strelka/mixins'
+
+# A Cookie that is encoded using SCS: Secure Cookie Sessions for HTTP
+#
+#   http://tools.ietf.org/html/draft-secure-cookie-session-protocol-06
+#
+class Strelka::SCSCookie < Strelka::Cookie
+	extend Configurability,
+	       Strelka::MethodUtilities
+
+
+	# Configure using the 'scs' section
+	config_key :scs
+
+
+	# The maximum size of the transform ID
+	SCS_TID_MAX  = 64
+
+	# A structure for storing keysets
+	KeySet = Struct.new( 'Strelka_SCSCookie_Keyset', :tid, :key, :hkey, :expires )
+
+
+	# Configurability
+	CONFIG_DEFAULTS = {
+		cipher_type:     'aes-128-cbc',
+		digest_type:     'sha1',
+		block_size:      16,
+		framing_byte:    '|',
+		session_max_age: 3600,
+		compression:     false,
+	}
+
+
+	##
+	# The cipher to use for encrypting the cookie data
+	singleton_attr_accessor :cipher_type
+	@cipher_type = CONFIG_DEFAULTS[:cipher_type]
+
+	##
+	# The digest algorithm to use for the message authentication
+	singleton_attr_accessor :digest_type
+	@digest_type = CONFIG_DEFAULTS[:digest_type]
+
+	##
+	# Number of bytes to use for the IV
+	singleton_attr_accessor :block_size
+	@block_size = CONFIG_DEFAULTS[:block_size]
+
+	##
+	# The explicit framing byte used to concatenate the parts of the authtag
+	singleton_attr_accessor :framing_byte
+	@framing_byte = CONFIG_DEFAULTS[:framing_byte]
+
+	##
+	# The maximum number of seconds a session is valid for
+	singleton_attr_accessor :session_max_age
+	@session_max_age = CONFIG_DEFAULTS[:session_max_age]
+
+	##
+	# If true, compress the payload of the cookie before encypting it
+	singleton_attr_accessor :compression
+	@compression = CONFIG_DEFAULTS[:compression]
+
+	##
+	# A KeySet used for creating auth tokens
+	singleton_attr_reader :current_keyset
+	@current_keyset = nil
+
+	##
+	# The most-recent expired keyset, used to validate cookies after the keyset has been
+	# rotated.
+	singleton_attr_reader :last_keyset
+	@last_keyset = nil
+
+
+	### Set the current keyset to +keyset+.
+	def self::current_keyset=( keyset )
+		self.last_keyset = self.current_keyset
+		self.log.info "Activating keyset %p; expires on %s" % [ keyset.tid, keyset.expires ]
+		@current_keyset = keyset
+	end
+
+
+	### Writer: set the most-recently-expired keyset to +keyset+.
+	def self::last_keyset=( keyset )
+		self.log.info "Rotating keysets: %p expired on %s" % [ keyset.tid, keyset.expires ] unless
+			keyset.nil?
+		@last_keyset = keyset
+	end
+
+
+	### Configurability API -- configure the class when the config is loaded.
+	def self::configure( config=nil )
+		if config
+			self.cipher_type     = config[:cipher_type]
+			self.digest_type     = config[:digest_type]
+			self.block_size      = config[:block_size]
+			self.framing_byte    = config[:framing_byte]
+			self.session_max_age = config[:session_max_age]
+			self.compression     = config[:compression]
+		else
+			self.cipher_type     = CONFIG_DEFAULTS[:cipher_type]
+			self.digest_type     = CONFIG_DEFAULTS[:digest_type]
+			self.block_size      = CONFIG_DEFAULTS[:block_size]
+			self.framing_byte    = CONFIG_DEFAULTS[:framing_byte]
+			self.session_max_age = CONFIG_DEFAULTS[:session_max_age]
+			self.compression     = CONFIG_DEFAULTS[:compression]
+		end
+
+		self.log.debug "Configured: cipher: %s, digest: %s, blksize: %d, framebyte: %p, maxage: %ds, compression: %s" % [
+			self.cipher_type,
+			self.digest_type,
+			self.block_size,
+			self.framing_byte,
+			self.session_max_age,
+			self.compression
+		]
+
+		# Reset the existing keys in case the cipher has changed.
+		# :FIXME: Remember the cipher so users don't have to auth again when the
+		# server is reloaded.
+		self.current_keyset = self.make_new_keyset( self.session_max_age )
+		self.last_keyset = nil
+
+		raise "The %p cipher is not implemented by your OpenSSL implementation" unless
+			OpenSSL::Cipher.ciphers.include?( self.cipher_type )
+	end
+
+
+	### Expire the current key and generate a new one if the current keyset is expired.
+	def self::rotate_keys
+		self.log.debug "Checking for expired keyset: %s" % [ self.current_keyset.expires ]
+		return unless self.current_keyset.expires <= Time.now
+		self.current_keyset = Strelka::SCSCookie.make_new_keyset( self.session_max_age )
+	end
+
+
+	### Turn a regular +cookie+ into an SCSCookie.
+	def self::from_regular_cookie( cookie )
+		self.log.debug "Upgrading cookie %p to a %p" % [ cookie, self ]
+
+		# First of all, the inbound scs-cookie-value is broken into its
+		# component fields which MUST be exactly 5, and each at least of the
+		# minimum length specified in Figure 1 (step 0.).  In case any of these
+		# preliminary checks fails, the PDU is discarded (step 13.); else TID
+		# is decoded to allow key-set lookup (step 1.).
+		encoded_fields = cookie.value.split( self.framing_byte )
+		self.log.debug "  split into fields: %p" % [ encoded_fields ]
+		unless encoded_fields.length == 5
+			self.log.info "Invalid SCS cookie: expected 5 fields, got %d" % [ encoded_fields.length ]
+			return nil
+		end
+
+		# If the cryptographic credentials (encryption and authentication
+		# algorithms and keys identified by TID) are unavailable (step 12.),
+		# the inbound SCS cookie is discarded since its value has no chance to
+		# be interpreted correctly.  This may happen for several reasons: e.g.,
+		# if a device without storage has been reset and loses the credentials
+		# stored in RAM, if a server pool node desynchronizes, or in case of a
+		# key compromise that forces the invalidation of all current TID's,
+		# etc.
+		data, atime, tid, iv, authtag = encoded_fields.collect {|f| f.unpack('m').first }
+		self.log.debug "  decoded fields: %p" % [[ data, atime, tid, iv, authtag ]]
+		unless keyset = self.find_keyset( tid )
+			self.log.info "Couldn't find keyset for %p; expired?"
+			return nil
+		end
+		self.log.debug "  found keyset for TID: %p" % [ tid ]
+
+		# When a valid key-set is found (step 2.), the AUTHTAG field is decoded
+		# (step 3.) and the (still) encoded DATA, ATIME, TID and IV fields are
+		# supplied to the primitive that computes the authentication tag (step
+		# 4.).
+		#
+		# If the tag computed using the local key-set matches the one carried
+		# by the supplied SCS cookie, we can be confident that the cookie
+		# carries authentic material; otherwise the SCS cookie is discarded
+		# (step 11.).
+		cookie_authtag = self.make_authtag( data, atime, iv, keyset )
+		self.log.debug "  challenge authtag is: %p" % [ cookie_authtag ]
+		unless authtag == cookie_authtag
+			self.log.info "Invalid SCS cookie: authtags don't match: %p vs. %p" %
+				[ authtag, cookie_authtag ]
+			return nil
+		end
+
+		# Then the age of the SCS cookie (as deduced by ATIME field value and
+		# current time provided by the server clock) is decoded and compared to
+		# the maximum time-to-live defined by the session_max_age parameter.
+		session_age = Time.now - Time.at( atime.to_i )
+		self.log.debug "  session is %d seconds old" % [ session_age ]
+		if session_age > self.session_max_age
+			self.log.info "Session expired %d seconds ago." % [ session_age - self.session_max_age ]
+			return nil
+		end
+
+		# In case the "age" check is passed, the DATA and IV fields are finally
+		# decoded (step 8.), so that the original plain text data can be
+		# extracted from the encrypted and optionally compressed blob (step
+		# 9.).
+		#
+		# Note that steps 5. and 7. allow any altered packets or expired
+		# sessions to be discarded, hence avoiding unnecessary state decryption
+		# and decompression.
+		value = self.decrypt( data, iv, keyset.key )
+		value = self.decompress( data ) if self.compression
+		self.log.debug "  decrypted cookie value is: %p" % [ value ]
+
+		return new( cookie.name, value, cookie.options )
+	end
+
+
+	### Create and return an instance of the configured OpenSSL::Cipher.
+	def self::make_cipher
+		shortname = self.cipher_type
+		return OpenSSL::Cipher.new( shortname )
+	end
+
+
+	### Create and return an instance of the OpenSSL::Digest.
+	def self::make_digest
+		shortname = self.digest_type
+		return OpenSSL::Digest.new( shortname )
+	end
+
+
+	### Encrypt the specified +data+ using the specified +key+ and +iv+.
+	def self::encrypt( data, iv, key )
+		cipher = self.make_cipher
+		cipher.encrypt
+		cipher.key = key
+		cipher.iv = iv
+
+		encrypted = cipher.update( data ) << cipher.final
+
+		return encrypted
+	end
+
+
+	### Decrypt the specified +data+ using the given +key+ and +iv+.
+	def self::decrypt( data, iv, key )
+		cipher = self.make_cipher
+		cipher.decrypt
+		cipher.key = key
+		cipher.iv = iv
+
+		decrypted = cipher.update( data ) << cipher.final
+
+		return decrypted
+	end
+
+
+	### Encode the cookie value as Base-64
+	def self::encode( value )
+		return [ value ].pack( 'm' ).chomp
+	end
+
+
+	### Decode the given +data+ using Base-64 and return the decoded value.
+	def self::decode( data )
+		return data.unpack( 'm' ).first
+	end
+
+
+	### Compress the specified +data+ and return it.
+	def self::compress( data )
+		return Zlib::Deflate.deflate( data )
+	end
+
+
+	### Demompress the specified +data+ and return it.
+	def self::decompress( data )
+		return Zlib::Inflate.inflate( data )
+	end
+
+
+	### Find the keyset associated with the given +tid+, either the current one
+	### or the most-recently-expired one.
+	def self::find_keyset( tid )
+		self.log.debug "Finding keyset for TID: %p" % [ tid ]
+		return [ self.current_keyset, self.last_keyset ].find {|ks| ks.tid == tid }
+	end
+
+
+	### Make an SCS authtag from the specified data, atime, and iv,
+	### plus the tid from the given +keyset+ and hashed with its hkey.
+	def self::make_authtag( data, atime, iv, keyset )
+		self.log.debug "Making authtag for data: %p, atime: %d, iv: %p, keyset: %p" %
+			[ data, atime, iv, keyset ]
+
+		# AUTHTAG := HMAC(e(DATA)||e(ATIME)||e(TID)||e(IV))
+		hashdata = [ data, atime.to_i, keyset.tid, iv ].
+			collect {|val| self.encode(val.to_s) }.
+			join( self.framing_byte )
+
+		return OpenSSL::HMAC.digest( self.digest_type, keyset.hkey, hashdata )
+	end
+
+
+	### Make a new key to use for encryption.
+	def self::make_new_key
+		key_size = self.make_cipher.key_len
+		return OpenSSL::Random.random_bytes( key_size )
+	end
+
+
+	### Make a new key to use for the HMAC.
+	def self::make_new_hkey
+		key_size = self.make_digest.size
+		return OpenSSL::Random.random_bytes( key_size )
+	end
+
+
+	### Generate a new transform ID of the specified +size+.
+	def self::make_new_tid
+		size = [ self.block_size, SCS_TID_MAX ].min
+		data = OpenSSL::Random.random_bytes( size )
+		# Shift bytes into visible ASCII:
+		#   http://goo.gl/8QIVE
+		return data.bytes.collect {|byte| (byte % 93) + 33 }.pack( 'C*' )
+	end
+
+
+	### Create a new keyset with its expiration set to +expires_at+, which can
+	### be a Time object, or an Integer (in which case its treated as the number of
+	### seconds until it expires).
+	def self::make_new_keyset( expires_at )
+		expires_at = Time.now + expires_at unless expires_at.is_a?( Time )
+		self.log.debug "Making a new keyset that expires on %s" % [ expires_at ]
+
+		ks         = KeySet.new
+		ks.tid     = self.make_new_tid
+		ks.key     = self.make_new_key
+		ks.hkey    = self.make_new_hkey
+		ks.expires = expires_at
+
+		return ks
+	end
+
+
+
+	#################################################################
+	###	I N S T A N C E   M E T H O D S
+	#################################################################
+
+	### Set up some additional values used for SCS.
+	def initialize( name, values, options={} ) # :notnew:
+		@atime       = Time.now
+		@keyset      = self.class.current_keyset
+		@iv          = OpenSSL::Random.random_bytes( self.class.block_size )
+
+		super
+	end
+
+
+	######
+	public
+	######
+
+	# The absolute timestamp of the last operation on the session data, as a Time object
+	attr_accessor :atime
+
+	# The keyset that will be used when creating this cookie's payload
+	attr_accessor :keyset
+
+	# The initialization vector used when encrypting the cookie's payload
+	attr_accessor :iv
+
+
+	### Return the cookie data as an encrypted string after (optionally) compressing
+	### it.
+	def encrypted_data
+		# 3.  DATA := Enc(Comp(plain-text-cookie-value), IV)
+		self.log.debug "Encoding value: %p" % [ self.value ]
+		plain_data = self.value
+		plain_data = self.class.compress( plain_data ) if self.class.compression
+
+		return self.class.encrypt( plain_data, self.iv, self.keyset.key )
+	end
+
+
+	### Make the encrypted cookie value.
+	def make_valuestring
+		data    = self.encrypted_data
+		authtag = self.class.make_authtag( data, self.atime, self.iv, self.keyset )
+
+		# scs-cookie-value  = eDATA "|" eATIME "|" eTID "|" eIV "|" eAUTHTAG
+		return [ data, self.atime.to_i, self.keyset.tid, self.iv, authtag ].
+			collect {|val| self.class.encode(val.to_s) }.
+			join( self.class.framing_byte )
+	end
+
+end # class Strelka::SCSCookie
+

spec/lib/helpers.rb

+#!/usr/bin/ruby
+# coding: utf-8
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname.new( __FILE__ ).dirname.parent.parent
+
+	srcdir = basedir.parent
+	libdir = basedir + "lib"
+
+	$LOAD_PATH.unshift( basedir.to_s ) unless $LOAD_PATH.include?( basedir.to_s )
+	$LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
+}
+
+# SimpleCov test coverage reporting; enable this using the :coverage rake task
+if ENV['COVERAGE']
+	$stderr.puts "\n\n>>> Enabling coverage report.\n\n"
+	require 'simplecov'
+	SimpleCov.start do
+		add_filter 'spec'
+		add_group "Needing tests" do |file|
+			file.covered_percent < 90
+		end
+	end
+end
+
+require 'loggability'
+require 'loggability/spechelpers'
+require 'configurability'
+
+require 'rspec'
+require 'mongrel2'
+require 'mongrel2/testing'
+
+require 'strelka'
+require 'strelka/testing'
+require 'strelka/authprovider'
+require 'strelka/authprovider/authtoken'
+
+
+Loggability.format_with( :color ) if $stdout.tty?
+
+
+### RSpec helper functions.
+module SpecHelpers
+end
+
+abort "You need a version of RSpec >= 2.6.0" unless defined?( RSpec )
+
+### Mock with RSpec
+RSpec.configure do |c|
+	include Strelka::Constants
+
+	c.mock_with( :rspec )
+	c.treat_symbols_as_metadata_keys_with_true_values = true
+
+	c.include( Loggability::SpecHelpers )
+	c.include( Mongrel2::SpecHelpers )
+	c.include( Strelka::Constants )
+	c.include( Strelka::Testing )
+	c.include( SpecHelpers )
+end
+
+# vim: set nosta noet ts=4 sw=4:
+

spec/strelka/authprovider/authtoken_spec.rb

-#!/usr/bin/env rspec -cfd -b
+# -*- rspec -*-
+# vim: set nosta noet ts=4 sw=4:
 
 BEGIN {
 	require 'pathname'
-	basedir = Pathname( __FILE__ ).dirname.parent
-	libdir = basedir + 'lib'
-
-	$LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
+	basedir = Pathname.new( __FILE__ ).dirname.parent.parent.parent
+	$LOAD_PATH.unshift( basedir ) unless $LOAD_PATH.include?( basedir )
 }
 
 require 'rspec'
-require 'strelka/_auth_token'
 
-describe Strelka-AuthToken do
+require 'spec/lib/helpers'
 
-	it "is well-tested" do
-		fail "it isn't"
+require 'strelka'
+require 'strelka/scscookie'
+require 'strelka/authprovider/authtoken'
+
+
+#####################################################################
+###	C O N T E X T S
+#####################################################################
+
+describe Strelka::AuthProvider::AuthToken do
+
+	before( :all ) do
+		Strelka::SCSCookie.configure
+		@cookie_name = described_class.cookie_name
+		@request_factory = Mongrel2::RequestFactory.new( route: '/admin' )
+		setup_logging()
+	end
+
+	before( :each ) do
+		@app = stub( "Strelka::App", :conn => stub("Connection", :app_id => 'test-app') )
+		@provider = Strelka::AuthProvider.create( :authtoken, @app )
+		@config = {
+			:realm => 'Pern',
+			:users => {
+				"lessa" => "8wiomemUvH/+CX8UJv3Yhu+X26k=",
+				"f'lar" => "NSeXAe7J5TTtJUE9epdaE6ojSYk=",
+			}
+		}
+	end
+
+	after( :each ) do
+		described_class.users = {}
+		described_class.realm = nil
+	end
+
+	after( :all ) do
+		reset_logging()
+	end
+
+
+	#
+	# Helpers
+	#
+
+	# Make a valid basic authorization header field
+	def make_auth_cookie( username )
+		cookie_name = described_class.cookie_name
+		return Strelka::SCSCookie.new( cookie_name, 'lessa', secure: true )
+	end
+
+
+	#
+	# Examples
+	#
+
+	it "uses the app ID as the basic auth realm if none is explicitly configured" do
+		described_class.realm.should == @app.conn.app_id
+	end
+
+	it "can be configured via the Configurability API" do
+		described_class.configure( @config )
+		described_class.realm.should == @config[:realm]
+		described_class.users.should == @config[:users]
+	end
+
+
+	context "unconfigured" do
+
+		before( :all ) do
+			described_class.configure( nil )
+		end
+
+		it "rejects a request with no scs cookie and no credential parameters" do
+			req = @request_factory.get( '/admin/console' )
+
+			expect {
+				@provider.authenticate( req )
+			}.to finish_with( HTTP::UNAUTHORIZED, /requires authentication/i ).
+			     and_header( www_authenticate: "AuthToken realm=test-app" )
+		end
+
+		it "rejects a request with an invalid SCS cookie" do
+			req = @request_factory.get( '/admin/console' )
+			req.cookies[ described_class.cookie_name ] = make_auth_cookie( 'lessa' )
+
+			expect {
+				@provider.authenticate( req )
+			}.to finish_with( HTTP::UNAUTHORIZED, /requires authentication/i )
+		end
+
+		it "accepts a request with a valid SCS cookie" do
+			auth_cookie = make_auth_cookie( 'lessa' )
+			req = @request_factory.get( '/admin/console' )
+			req.headers.cookie = auth_cookie.to_s
+
+			@provider.authenticate( req ).should == 'lessa'
+		end
+
+	end
+
+
+	context "configured with at least one user" do
+
+		before( :all ) do
+			described_class.configure( @config )
+		end
+
+		it "rejects a request with no scs cookie and no credential parameters" do
+			req = @request_factory.get( '/admin/console' )
+
+			expect {
+				@provider.authenticate( req )
+			}.to finish_with( HTTP::UNAUTHORIZED, /requires authentication/i ).
+			     and_header( www_authenticate: "AuthToken realm=test-app" )
+		end
+
+		it "rejects a request with an invalid SCS cookie" do
+			invalid_cookie = Strelka::Cookie.new( described_class.cookie_name, 'username' )
+			req = @request_factory.get( '/admin/console' )
+			req.cookies[ described_class.cookie_name ] = invalid_cookie
+
+			expect {
+				@provider.authenticate( req )
+			}.to finish_with( HTTP::UNAUTHORIZED, /requires authentication/i )
+		end
+
 	end
 
 end

spec/strelka/scscookie_spec.rb

+#!/usr/bin/env rspec -cfd -b
+
+BEGIN {
+	require 'pathname'
+	basedir = Pathname( __FILE__ ).dirname.parent.parent
+	libdir = basedir + 'lib'
+
+	$LOAD_PATH.unshift( basedir.to_s ) unless $LOAD_PATH.include?( basedir.to_s )
+	$LOAD_PATH.unshift( libdir.to_s ) unless $LOAD_PATH.include?( libdir.to_s )
+}
+
+require 'loggability/spechelpers'
+require 'openssl'
+require 'timecop'
+
+require 'rspec'
+require 'strelka/scscookie'
+
+require 'spec/lib/helpers'
+
+
+# These examples are from Appendix A.
+#
+# A.1.  No Compression
+#
+#    The following parameters:
+#
+#    o  Plain text cookie: "a state string"
+#    o  AES-CBC-128 key: "123456789abcdef"
+#    o  HMAC-SHA1 key: "12345678901234567890"
+#    o  TID: "tid"
+#    o  ATIME: 1347265955
+#    o  IV:
+#       \xb4\xbd\xe5\x24\xf7\xf6\x9d\x44\x85\x30\xde\x9d\xb5\x55\xc9\x4f
+#
+#    produce the following tokens:
+#
+#    o  DATA: DqfW4SFqcjBXqSTvF2qnRA
+#    o  ATIME: MTM0NzI2NTk1NQ
+#    o  TID: OHU7M1cqdDQt
+#    o  IV: tL3lJPf2nUSFMN6dtVXJTw
+#    o  AUTHTAG: AznYHKga9mLL8ioi3If_1iy2KSA
+#
+describe Strelka::SCSCookie do
+
+	COOKIE_DATA     = 'a state string'
+	AES_CBC_128_KEY = '123456789abcdef0'
+	HMAC_SHA1_KEY   = '12345678901234567890'
+	TID             = 'tid'
+	ATIME           = 1347265955
+	IV              = "\xb4\xbd\xe5\x24\xf7\xf6\x9d\x44\x85\x30\xde\x9d\xb5\x55\xc9\x4f"
+
+
+	before( :all ) do
+		setup_logging()
+	end
+
+	after( :all ) do
+		reset_logging()
+	end
+
+
+	it "can be upgraded from a regular cookie" do
+		cookie = Strelka::Cookie.new( 'token', 'a value' )
+		scs_cookie = described_class.from_regular_cookie( cookie )
+		scs_cookie.should be_a( described_class )
+		scs_cookie.value.should == cookie.value
+	end
+
+
+	context "A.1. No Compression" do
+
+		before( :all ) do
+			@time = Time.at( ATIME )
+			Timecop.freeze( @time )
+		end
+
+		let( :cookie ) do
+			cookie = described_class.new( 'authcookie', 'a state string',
+			                                key: AES_CBC_128_KEY,
+                                           hkey: HMAC_SHA1_KEY,
+                                            tid: TID,
+                                          atime: ATIME )
+			cookie.instance_variable_set( :@iv, IV )
+			cookie
+		end
+
+		it "has an encoded data block that matches example from the spec" do
+			cookie.encoded_data.should == "XoYbblL/ap13ye+i6uQULg=="
+		end
+
+		it "has an encoded atime block that matches example from the spec" do
+			cookie.encoded_atime.should == 'MTM0NzI2NTk1NQ=='
+		end
+
+		it "has an encoded TID block that matches example from the spec" do
+			cookie.encoded_tid.should == "dGlk"
+		end
+
+		it "has an encoded IV block that matches example from the spec" do
+			cookie.encoded_iv.should == 'tL3lJPf2nUSFMN6dtVXJTw=='
+		end
+
+		it "has an encoded authtag block that matches example from the spec" do
+			cookie.encoded_authtag.should == '0xPSM7RDVytACfRuqlkRKucxEOM='
+		end
+
+		it "stringifies as a valid cookie" do
+			cookie.to_s.should == 'authcookie=XoYbblL/ap13ye+i6uQULg==|' +
+				'MTM0NzI2NTk1NQ==|dGlk|tL3lJPf2nUSFMN6dtVXJTw==|0xPSM7RD' +
+				'VytACfRuqlkRKucxEOM='
+		end
+
+	end
+
+
+end
+