class Rack::OAuth2::Server
Implements an OAuth 2 Authorization Server
, based on tools.ietf.org/html/draft-ietf-oauth-v2-10
Constants
- Options
Options
are:-
:access_token_path – Path for requesting access token. By convention defaults to /oauth/access_token.
-
:authenticator – For username/password authorization. A block that receives the credentials and returns identity string (e.g. user ID) or nil.
-
:authorization_types – Array of supported authorization types. Defaults to [“code”, “token”], and you can change it to just one of these names.
-
:authorize_path – Path for requesting end-user authorization. By convention defaults to /oauth/authorize.
-
:database – Mongo::DB instance (this is a global option).
-
:expires_in – Number of seconds an auth token will live. If nil or zero, access token never expires.
-
:host – Only check requests sent to this host.
-
:path – Only check requests for resources under this path.
-
:param_authentication – If true, supports authentication using query/form parameters.
-
:realm – Authorization realm that will show up in 401 responses. Defaults to use the request host name.
-
:logger – The logger to use. Under
Rails
, defaults to use theRails
logger. Will use Rack::Logger if available. -
:collection_prefix – Prefix to use for MongoDB collections created by rack-oauth2-server. Defaults to “oauth2”.
Authenticator is a block that receives either two or four parameters. The first two are username and password. The other two are the client identifier and scope. It authenticated, it returns an identity, otherwise it can return nil or false. For example:
oauth.authenticator = lambda do |username, password| user = User.find_by_username(username) user if user && user.authenticated?(password) end
Assertion handler is a hash of blocks keyed by assertion_type. Blocks receive three parameters: the client, the assertion, and the scope. If authenticated, it returns an identity. Otherwise it can return nil or false. For example:
oauth.assertion_handler['facebook.com'] = lambda do |client, assertion, scope| facebook = URI.parse('https://graph.facebook.com/me?access_token=' + assertion) response = Net::HTTP.get_response(facebook) user_data = JSON.parse(response.body) user = User.from_facebook_data(user_data) end
Assertion handlers are optional; if one is not present for a given assertion type, no error will result.
-
- VERSION
Same as gem version number.
Attributes
Public Class Methods
Creates and returns a new access grant. Actually, returns only the authorization code which you can turn into an access token by making a request to /oauth/access_token.
@param [String,Integer] identity User ID, account ID, etc @param [String] client_id Client
identifier @param [Array, nil] scope Array of string, nil if you want ‘em all @param [Integer, nil] expires_in How many seconds before access grant expires (default to 5 minutes) @return [String] Access grant authorization code
# File lib/rack/oauth2/server.rb, line 87 def access_grant(identity, client_id, scope = nil, expires_in = nil) client = get_client(client_id) or fail "No such client" AccessGrant.create(identity, client, scope || client.scope, nil, expires_in).code end
@private
# File lib/rack/oauth2/models.rb, line 27 def create_indexes(&block) if block @create_indexes ||= [] @create_indexes << block elsif @create_indexes @create_indexes.each do |block| block.call end @create_indexes = nil end end
A Mongo::DB object.
# File lib/rack/oauth2/models.rb, line 40 def database @database ||= Server.options.database raise "No database Configured. You must configure it using Server.options.database = Mongo::Connection.new()[db_name]" unless @database raise "You set Server.database to #{@database.class}, should be a Mongo::DB object" unless Mongo::DB === @database @database end
Returns AccessToken
from token.
@param [String] token Access token (e.g. from oauth.access_token) @return [AccessToken]
# File lib/rack/oauth2/server.rb, line 96 def get_access_token(token) AccessToken.from_token(token) end
Return AuthRequest
from authorization request handle.
@param [String] authorization Authorization handle (e.g. from oauth.authorization) @return [AuthReqeust]
# File lib/rack/oauth2/server.rb, line 24 def get_auth_request(authorization) AuthRequest.find(authorization) end
Returns an Issuer
from it’s identifier.
@param [String] identifier the Issuer’s identifier @return [Issuer]
# File lib/rack/oauth2/server.rb, line 153 def get_issuer(identifier) Issuer.from_identifier(identifier) end
Returns all AccessTokens for an identity.
@param [String] identity Identity, e.g. user ID, account ID @return [Array<AccessToken>]
# File lib/rack/oauth2/server.rb, line 119 def list_access_tokens(identity) AccessToken.from_identity(identity) end
# File lib/rack/oauth2/server.rb, line 217 def initialize(app, options = nil, &authenticator) @app = app @options = options || Server.options @options.authenticator ||= authenticator @options.assertion_handler ||= {} @options.access_token_path ||= "/oauth/access_token" @options.authorize_path ||= "/oauth/authorize" @options.authorization_types ||= %w{code token} @options.param_authentication ||= false @options.collection_prefix ||= "oauth2" end
Create new instance of the klass and populate its attributes.
# File lib/rack/oauth2/models.rb, line 12 def new_instance(klass, fields) return unless fields instance = klass.new fields.each do |name, value| instance.instance_variable_set :"@#{name}", value end instance end
Global options. This is what we set during configuration (e.g. Rails’ config/application), and options all handlers inherit by default.
# File lib/rack/oauth2/server.rb, line 211 def self.options @options end
Registers and returns a new Client
. Can also be used to update existing client registration, by passing identifier (and secret) of existing client record. That way, your setup script can create a new client application and run repeatedly without fail.
@param [Hash] args Arguments for registering client application @option args [String] :id Client
identifier. Use this to update existing client registration (in combination wih secret) @option args [String] :secret Client
secret. Use this to update existing client registration. @option args [String] :display_name Name to show when authorizing access (e.g. “My Awesome Application”) @option args [String] link Link to client application’s Web site @option args [String] image_url URL of image to show alongside display name. @option args [String] redirect_uri Redirect URL: authorization requests for this client will always redirect back to this URL. @option args [Array] scope Scope that client application can request (list of names). @option args [Array] notes Free form text, for internal use.
@example Registering new client application
Server.register :display_name=>"My Application", :link=>"http://example.com", :scope=>%w{read write}, :redirect_uri=>"http://example.com/oauth/callback"
@example Migration using configuration file
config = YAML.load_file(Rails.root + "config/oauth.yml") Server.register config["id"], config["secret"], :display_name=>"My Application", :link=>"http://example.com", :scope=>config["scope"], :redirect_uri=>"http://example.com/oauth/callback"
# File lib/rack/oauth2/server.rb, line 68 def register(args) if args[:id] && args[:secret] && (client = get_client(args[:id])) fail "Client secret does not match" unless client.secret == args[:secret] client.update args else Client.create(args) end end
Registers and returns a new Issuer
. Can also be used to update existing Issuer
, by passing the identifier of an existing Issuer
record. That way, your setup script can create a new client application and run repeatedly without fail.
@param [Hash] args Arguments for registering Issuer
@option args [String] :identifier Issuer
identifier. Use this to update an existing Issuer
@option args [String] :hmac_secret The HMAC secret for this Issuer
@option args [String] :public_key The RSA public key (in PEM format) for this Issuer
@option args [Array] :notes Free form text, for internal use.
@example Registering new Issuer
Server.register_issuer :hmac_secret=>"foo", :notes=>"Company A"
@example Migration using configuration file
config = YAML.load_file(Rails.root + "config/oauth.yml") Server.register_issuer config["id"], :hmac_secret=>"bar", :notes=>"Company A"
# File lib/rack/oauth2/server.rb, line 141 def register_issuer(args) if args[:identifier] && (issuer = get_issuer(args[:identifier])) issuer.update(args) else Issuer.create(args) end end
Long, random and hexy.
# File lib/rack/oauth2/models.rb, line 22 def secure_random OpenSSL::Random.random_bytes(32).unpack("H*")[0] end
Returns AccessToken
for the specified identity, client application and scope. You can use this method to request existing access token, new token generated if one does not already exists.
@param [String,Integer] identity Identity, e.g. user ID, account ID @param [String] client_id Client
application identifier @param [Array, nil] scope Array of names, nil if you want ‘em all @param [Integer, nil] expires How many seconds before access token expires, defaults to never. If zero or nil, token never expires. @return [String] Access token
# File lib/rack/oauth2/server.rb, line 110 def token_for(identity, client_id, scope = nil, expires_in = nil) client = get_client(client_id) or fail "No such client" AccessToken.get_token_for(identity, client, scope || client.scope, expires_in).token end
Public Instance Methods
# File lib/rack/oauth2/server.rb, line 232 def call(env) request = OAuthRequest.new(env) return @app.call(env) if options.host && options.host != request.host return @app.call(env) if options.path && request.path.index(options.path) != 0 logger = options.logger || env["rack.logger"] # 3. Obtaining End-User Authorization # Flow starts here. return request_authorization(request, logger) if request.path == options.authorize_path # 4. Obtaining an Access Token return respond_with_access_token(request, logger) if request.path == options.access_token_path # 5. Accessing a Protected Resource if request.authorization # 5.1.1. The Authorization Request Header Field token = request.credentials if request.oauth? elsif options.param_authentication && !request.GET["oauth_verifier"] # Ignore OAuth 1.0 callbacks # 5.1.2. URI Query Parameter # 5.1.3. Form-Encoded Body Parameter token = request.GET["oauth_token"] || request.POST["oauth_token"] token ||= request.GET['access_token'] || request.POST['access_token'] end if token begin access_token = AccessToken.from_token(token) raise InvalidTokenError if access_token.nil? || access_token.revoked raise ExpiredTokenError if access_token.expires_at && access_token.expires_at <= Time.now.to_i request.env["oauth.access_token"] = token request.env["oauth.identity"] = access_token.identity access_token.access! logger.info "RO2S: Authorized #{access_token.identity}" if logger rescue OAuthError=>error # 5.2. The WWW-Authenticate Response Header Field logger.info "RO2S: HTTP authorization failed #{error.code}" if logger return unauthorized(request, error) rescue =>ex logger.info "RO2S: HTTP authorization failed #{ex.message}" if logger return unauthorized(request) end # We expect application to use 403 if request has insufficient scope, # and return appropriate WWW-Authenticate header. response = @app.call(env) if response[0] == 403 scope = Utils.normalize_scope(response[1].delete("oauth.no_scope")) challenge = 'OAuth realm="%s", error="insufficient_scope", scope="%s"' % [(options.realm || request.host), scope.join(" ")] response[1]["WWW-Authenticate"] = challenge return response else return response end else response = @app.call(env) if response[1] && response[1].delete("oauth.no_access") logger.debug "RO2S: Unauthorized request" if logger # OAuth access required. return unauthorized(request) elsif response[1] && response[1]["oauth.authorization"] # 3. Obtaining End-User Authorization # Flow ends here. return authorization_response(response, logger) else return response end end end
Protected Instance Methods
# File lib/rack/oauth2/server.rb, line 495 def bad_request(message) return [400, { "Content-Type"=>"text/plain" }, [message]] end
Returns client from request based on credentials. Raises InvalidClientError
if client doesn’t exist or secret doesn’t match.
# File lib/rack/oauth2/server.rb, line 469 def get_client(request, options={}) # 2.1 Client Password Credentials if request.basic? client_id, client_secret = request.credentials elsif request.post? client_id, client_secret = request.POST.values_at("client_id", "client_secret") else client_id, client_secret = request.GET.values_at("client_id", "client_secret") end client = self.class.get_client(client_id) raise InvalidClientError if !client unless options[:dont_authenticate] raise InvalidClientError unless client.secret == client_secret end raise InvalidClientError if client.revoked return client rescue BSON::InvalidObjectId raise InvalidClientError end
Processes a JWT assertion
# File lib/rack/oauth2/server.rb, line 507 def process_jwt_assertion(assertion) begin require 'jwt' require 'json' require 'openssl' require 'time' # JWT.decode only returns the claims. Gotta get the header ourselves header = JSON.parse(JWT.base64url_decode(assertion.split('.')[0])) algorithm = header['alg'] payload = JWT.decode(assertion, nil, false) raise InvalidGrantError, "missing issuer claim" if !payload.has_key?('iss') issuer_identifier = payload['iss'] issuer = Issuer.from_identifier(issuer_identifier) raise InvalidGrantError, 'Invalid issuer' if issuer.nil? if algorithm =~ /^HS/ validated_payload = JWT.decode(assertion, issuer.hmac_secret, true) elsif algorithm =~ /^RS/ validated_payload = JWT.decode(assertion, OpenSSL::PKey::RSA.new(issuer.public_key), true) end raise InvalidGrantError, "missing principal claim" if !validated_payload.has_key?('prn') raise InvalidGrantError, "missing audience claim" if !validated_payload.has_key?('aud') raise InvalidGrantError, "missing expiration claim" if !validated_payload.has_key?('exp') expires = validated_payload['exp'].to_i # add a 10 minute fudge factor for clock skew between servers skewed_expires_time = expires + (10 * 60) now = Time.now.utc.to_i raise InvalidGrantError, "expired claims" if skewed_expires_time <= now principal = validated_payload['prn'] principal rescue JWT::DecodeError => de raise InvalidGrantError, de.message rescue JSON::ParserError => pe raise InvalidGrantError, "Invalid segment encoding" end end
Rack
redirect response. The argument is typically a URI object, and the status should be a 302 or 303.
# File lib/rack/oauth2/server.rb, line 491 def redirect_to(uri, status = 302) return [status, { "Content-Type"=>"text/plain", "Location"=>uri.to_s }, ["You are being redirected"]] end
-
Obtaining an Access Token
# File lib/rack/oauth2/server.rb, line 402 def respond_with_access_token(request, logger) return [405, { "Content-Type"=>"application/json" }, ["POST only"]] unless request.post? # 4.2. Access Token Response begin client = get_client(request) case request.POST["grant_type"] when "none" # 4.1 "none" access grant type (i.e. two-legged OAuth flow) requested_scope = request.POST["scope"] ? Utils.normalize_scope(request.POST["scope"]) : client.scope access_token = AccessToken.create_token_for(client, requested_scope, nil, options.expires_in) when "authorization_code" # 4.1.1. Authorization Code grant = AccessGrant.from_code(request.POST["code"]) raise InvalidGrantError, "Wrong client" unless grant && client.id == grant.client_id unless client.redirect_uri.nil? || client.redirect_uri.to_s.empty? raise InvalidGrantError, "Wrong redirect URI" unless grant.redirect_uri == Utils.parse_redirect_uri(request.POST["redirect_uri"]).to_s end raise InvalidGrantError, "This access grant expired" if grant.expires_at && grant.expires_at <= Time.now.to_i access_token = grant.authorize!(options.expires_in) when "password" raise UnsupportedGrantType unless options.authenticator # 4.1.2. Resource Owner Password Credentials username, password = request.POST.values_at("username", "password") raise InvalidGrantError, "Missing username/password" unless username && password requested_scope = request.POST["scope"] ? Utils.normalize_scope(request.POST["scope"]) : client.scope allowed_scope = client.scope raise InvalidScopeError unless (requested_scope - allowed_scope).empty? args = [username, password] args << client.id << requested_scope unless options.authenticator.arity == 2 identity = options.authenticator.call(*args) raise InvalidGrantError, "Username/password do not match" unless identity access_token = AccessToken.get_token_for(identity, client, requested_scope, options.expires_in) when "assertion" # 4.1.3. Assertion requested_scope = request.POST["scope"] ? Utils.normalize_scope(request.POST["scope"]) : client.scope assertion_type, assertion = request.POST.values_at("assertion_type", "assertion") raise InvalidGrantError, "Missing assertion_type/assertion" unless assertion_type && assertion # TODO: Add other supported assertion types (i.e. SAML) here if assertion_type == "urn:ietf:params:oauth:grant-type:jwt-bearer" identity = process_jwt_assertion(assertion) access_token = AccessToken.get_token_for(identity, client, requested_scope, options.expires_in) elsif options.assertion_handler[assertion_type] args = [client, assertion, requested_scope] identity = options.assertion_handler[assertion_type].call(*args) raise InvalidGrantError, "Unknown assertion for #{assertion_type}" unless identity access_token = AccessToken.get_token_for(identity, client, requested_scope, options.expires_in) else raise InvalidGrantError, "Unsupported assertion_type" if assertion_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" end else raise UnsupportedGrantType end logger.info "RO2S: Access token #{access_token.token} granted to client #{client.display_name}, identity #{access_token.identity}" if logger response = { :access_token=>access_token.token } response[:scope] = access_token.scope.join(" ") return [200, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, [response.to_json]] # 4.3. Error Response rescue OAuthError=>error logger.error "RO2S: Access token request error #{error.code}: #{error.message}" if logger return unauthorized(request, error) if InvalidClientError === error && request.basic? return [400, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, [{ :error=>error.code, :error_description=>error.message }.to_json]] end end