/samba

Single Sign On authentication for Lucky framework

Primary LanguageCrystalMIT LicenseMIT

Samba

Samba is a Single Sign On authentication solution for Lucky framework. It extends Shield's OAuth 2 implementation with authentication capabilities.

Samba allows a user to log in once in an organization, and gain automatic access other apps in the organization. Conversely, when a user logs out of one app, they are automatically logged out of all other apps.

Samba defines two roles:

  1. Server: An OAuth 2 authorization server maintained by your organization.

  2. Client: Any application within your organization, other than the Samba Server, whose user identification and authentication functions are handled by the Server.

Installation

The Server

You should already have an OAuth 2 authorization server. See Shield's documentation for details.

  1. Add the dependency to your shard.yml:

    # ->>> shard.yml
    
    # ...
    dependencies:
      samba:
        github: GrottoPress/samba
    # ...
  2. Run shards install

  3. Require Samba in your app:

    # ->>> src/app.cr
    
    # ...
    require "samba/server"
    # ...
  4. Require presets, right after models:

    # ->>> src/app.cr
    
    # ...
    require "./models/base_model"
    require "./models/**"
    
    require "samba/presets/server"
    # ...

The Client

  1. Add the dependency to your shard.yml:

    # ->>> shard.yml
    
    # ...
    dependencies:
      samba:
        github: GrottoPress/samba
    # ...
  2. Run shards install

  3. Require Samba in your app:

    # ->>> src/app.cr
    
    # ...
    require "samba/client"
    # ...
  4. Require presets, right after models:

    # ->>> src/app.cr
    
    # ...
    require "./models/base_model"
    require "./models/**"
    
    require "samba/presets/client"
    # ...

Usage

The Server

  1. Set up actions:

    # ->>> src/actions/oauth/authorization/create.cr
    
    class Oauth::Authorization::Create < BrowserAction
      # ...
      include Samba::Oauth::Authorization::Create
    
      post "/oauth/authorization" do
        run_operation
      end
    
      #def do_run_operation_succeeded(operation, oauth_grant)
      #  code = OauthGrantCredentials.new(operation, oauth_grant)
      #  redirect to: oauth_redirect_uri(code: code.to_s, state: state).to_s
      #end
    
      #def do_run_operation_failed(operation)
      #  error = operation.granted.value ? "invalid_request" : "access_denied"
      #  redirect to: oauth_redirect_uri(error: error, state: state).to_s
      #end
      # ...
    end

    Samba::Oauth::Authorization::Create modifies Shield::Oauth::Authorization::Create to set the OAuth client ID in session after a successful authorization code request.

    These client IDs are used to determine which BearerLogin tokens to revoke whenever a user logs out.


    # ->>> src/actions/current_login/destroy.cr
    
    class CurrentLogin::Destroy < BrowserAction
      # ...
      include Samba::CurrentLogin::Destroy
    
      get "/logout" do
        run_operation
      end
    
      #def do_run_operation_succeeded(operation, login)
      #  flash.success = Rex.t(:"action.current_login.destroy.success")
      #  redirect to: New
      #end
    
      #def do_run_operation_failed(operation)
      #  flash.failure = Rex.t(:"action.current_login.destroy.failure")
      #  redirect_back fallback: CurrentUser::Show
      #end
      # ...
    end

    This action is used for Single Sign Out. All a Samba Client has to do is point its logout link to this URL.

  2. Set up i18n:

    Samba uses Rex for i18n. See https://github.com/GrottoPress/rex.

    Use the following as a guide to set up translations:

    action:
      current_login:
        destroy:
          failure: Something went wrong
          success: You have logged out successfully
    

The Client

Each Samba Client must be registered with the Samba Server as a confidential OAuth client, if it is a full stack monolith.

If a Samba Client is an API backend, each of its frontend apps, rather, must be registered with the Server. They may or may not be confidential OAuth clients.

  1. Configure:

    # ->>> config/samba.cr
    
    Samba.configure do |settings|
      # ...
      # Set to `nil` if this app is an API backend. The frontend app should be
      # doing the authorization code request.
      settings.oauth_authorization_endpoint = "https://samba.server/oauth/authorize"
    
      # The OAuth client details
      # If this app is an API backend, set to `nil`.
      settings.oauth_client = {
        id: "x9y8z7",
        # This URI must match what was used to register the OAuth client
        redirect_uri: Oauth::Callback.url_without_query_params,
        secret: "a1b2c3"
      }
    
      # Additional trusted OAuth clients whose tokens are accepted for
      # authentication. If this app is an API backend, set this to all the OAuth
      # client IDs of all its frontend apps. Otherwise, leave empty.
      settings.oauth_client_ids = ["def456"]
    
      settings.oauth_token_endpoint = "https://samba.server/oauth/token"
    
      settings.oauth_token_introspection_endpoint =
        "https://samba.server/oauth/token/verify"
    
      # The challenge method to use for authorization code requests
      settings.oauth_code_challenge_method = "S256"
    
      # *Samba* makes an API call to the OAuth introspection endpoint whenever a
      # request is received. This setting allows caching the response.
      settings.verify_oauth_token = ->(key : String, verify : -> OauthToken) do
        # This example uses Dude (https://github.com/GrottoPress/dude), but you
        # may use any caching engine of choice
        #
        # `verify.call` is what actually does the API call
        Dude.get(OauthToken, key, 30.seconds) { verify.call }
      end
    
      # This token may be used when making token introspection requests.
      # It is required if this app is an API backend. Otherwise, if you do
      # not need to use it in any way, set to `nil`.
      #
      # This is typically a user-generated bearer token with access to the
      # token instrospection endpoint, at least.
      settings.server_api_token = "g4h5i6"
      # ...
    
      # A Client sends an authorization code request with the "sso" scope to
      # signal to the Server this is an authentication request.
      #
      # Specify additional scopes to request when sending the authorization code
      # request.
      #
      # (You'd typically want access to some sort of a user info endpoint
      # that the Server exposes)
      settings.login_token_scopes = ["server.current_user.show"]
    end
  2. Set up models:

    # ->>> src/models/user.cr
    
    class User < BaseModel
      # ...
      table :users do
        # ...
        column remote_id : Int64 # or `Int64?`
        # ...
      end
    
      # Since this app is within your organization, you may not want to
      # duplicate user information across apps. The recommendation is
      # to keep all user data at the Server, and define a method at each
      # Client that fetches the info from the Server when needed.
      #
      # This example uses Dude (https://github.com/GrottoPress/dude) for
      # caching.
      #
      # Another recommendation is to have all Clients share a single cache
      # store, so that when a Client writes data to the store, other Clients
      # can read it without going to the Server.
      getter remote : RemoteUser? do
        key = "users:#{remote_id}"
        Dude.get(RemoteUser, key, 3.minutes) { remote! }
      end
    
      def remote! : RemoteUser?
        # The server API token must have the needed scope to access
        # endpoint being requested
        token = Samba.settings.server_api_token
        # Do a HTTP GET for the user using the server API token
      end
    
      # An example for using with Carbon mailer
      #
      include Carbon::Emailable
    
      def email : String
        remote.not_nil!.email.not_nil!
      end
    
      def emailable : Carbon::Address
        Carbon::Address.new(email)
      end
      # ...
    end

    The remote_id column is required. The type of this column should match the primary key type of the User model of the Samba Server.

  3. Set up migrations:

    # ->>> db/migrations/XXXXXXXXXXXXXX_create_users.cr
    
    class CreateUsers::VXXXXXXXXXXXXXX < Avram::Migrator::Migration::V1
      def migrate
        create :users do
          # ...
          add remote_id : Int64, unique: true
          # ...
        end
      end
    
      def rollback
        drop :users
      end
    end
  4. Set up actions:

    While the setup instructions here are for full stack monoliths, there are API equivalents of each action that should be used when building a decoupled API backend.

    # ->>> src/actions/browser_action.cr
    
    abstract class BrowserAction < Lucky::Action
      # ...
      include Samba::LoginHelpers
      include Samba::LoginPipes
    
      #skip :pin_login_to_ip_address
    
      #def do_require_logged_out_failed
      #  flash.info = Rex.t(:"action.pipe.not_logged_out")
      #  redirect_back fallback: CurrentUser::Show
      #end
    
      #def do_check_authorization_failed
      #  flash.failure = Rex.t(:"action.pipe.authorization_failed")
      #  redirect_back fallback: CurrentUser::Show
      #end
      # ...
    end

    # ->>> src/actions/oauth/callback.cr
    
    class Oauth::Callback < BrowserAction
      # ...
      include Samba::Oauth::Token::Create
    
      get "/oauth/callback" do
        run_operation
      end
    
      #def do_run_operation_succeeded(operation, oauth_token)
      #  return invalid_scope_response unless oauth_token.sso?
      #  redirect_back fallback: CurrentUser::Show
      #end
    
      #def do_run_operation_failed(operation)
      #  json({
      #    error: "invalid_request",
      #    error_description: operation.errors.first_value.first
      #  })
      #end
      # ...
    end

    This action must match the redirect URI registered for the client.

  5. Set up i18n:

    Samba uses Rex for i18n. See https://github.com/GrottoPress/rex.

    Use the following as a guide to set up translations:

    action:
      pipe:
        authorization_failed: You are not allowed to perform this action
        not_logged_in: You are not logged in
        not_logged_out: You are logged in
    
        oauth:
          client_not_authorized: Client is not allowed to perform this action
          code_required: Authorization code is required
          sso_only: Only authentication (SSO) is supported
          state_invalid: Forged response detected!
    
    operation:
      error:
        remote_id_required: Remote ID is required
        remote_id_exists: Remote user has already been added
    
        oauth:
          code_required: Authorization code is required
          client_id_required: Client ID is required
          client_secret_required: Client secret is required
          redirect_uri_required: Redirect URI is required

Federation

While Samba is designed for use in your own organization, it should not stand in your way if you decide to bolt on authentication from Identity Providers outside your organization.

For instance, you may add a "Log in with GitHub" button to your Samba Server's login page, that allows your users to log in with GitHub. Samba does not care how the user logs in.

When your user is logged in at your Samba Server, however they were logged in, Samba would log them in automatically if the user tries to access any of your organization's apps.

Note, however, that Samba itself cannot be used to implement a "Log in with GitHub" login flow, for instance. You may need to read the GitHub API, and use whatever libraries and tools they provide for such a purpose.

If you decide to go federated, only your Samba Server should interact with services outside your organization. The server may be registered with the third-party provider as an OAuth client for such a purpose.

Testing

The Server

See Shield's documentation for details.

The Client

Setting up:

  1. Install manastech/webmock.cr as a development dependency

  2. Require Samba Client spec:

    # ->>> spec/spec_helper.cr
    
    # ...
    require "samba/spec/client"
    # ...

    This pulls in various types and helpers for specs.

  3. Set up API client:

    # ->>> spec/support/api_client.cr
    
    class ApiClient < Lucky::BaseHTTPClient
      def initialize
        super
        headers("Content-Type": "application/json")
      end
    end

    Samba comes with Samba::HttpClient, which enables API and browser authentication in Client specs.

Authenticating:

  • Browser authentication

    client = ApiClient.new
    
    # Creates a user and logs them in with a fake token.
    # You may optionally pass in `scopes` and `session`.
    client.browser_auth(remote_id)
    
    # Logs in a user that is already created.
    # You may optionally pass in `scopes` and `session`.
    client.browser_auth(user)
    
    # Go ahead and make requests to routes with the authenticated client.
    client.exec(CurrentUser::Show)
  • API authentication

    client = ApiClient.new
    
    # Creates a user and logs them in with a fake token.
    # You may optionally pass in `scopes` and `session`.
    client.api_auth(remote_id)
    
    # Logs in a user that is already created.
    # You may optionally pass in `scopes` and `session`.
    client.api_auth(user)
    
    # Go ahead and make requests to routes with
    # the authenticated client.
    client.exec(Api::CurrentUser::Show)
  • Set cookie header from session

    client = ApiClient.new
    session = Lucky::Session.new
    
    session.set(:one, "one")
    session.set(:two, "two")
    
    # Sets "Cookie" header from session
    client.set_cookie_from_session(session)
    
    # Go ahead and make requests.
    client.exec(Numbers::Show)

Development

Create a .env file:

CLIENT_CACHE_REDIS_URL=redis://localhost:6379/0
CLIENT_DATABASE_URL=postgres://postgres:password@localhost:5432/samba_client_spec
SERVER_DATABASE_URL=postgres://postgres:password@localhost:5432/samba_server_spec

Update the file with your own details, then run tests as follows:

  • Run Client tests with crystal spec spec/client
  • Run Server tests with crystal spec spec/server

Do not run client and server tests together; you would get a compile error.

Contributing

  1. Fork it
  2. Switch to the master branch: git checkout master
  3. Create your feature branch: git checkout -b my-new-feature
  4. Make your changes, updating changelog and documentation as appropriate.
  5. Commit your changes: git commit
  6. Push to the branch: git push origin my-new-feature
  7. Submit a new Pull Request against the GrottoPress:master branch.