pika-auth

Authentication for pika

pika-auth

Authentication strategies for Pika — plug-in Bearer token, API key, and HTTP Basic auth with class-level or per-resource control.

Installation

Add to your shard.yml:

dependencies:
  pika-auth:
    github: tekanic/pika-auth
    version: "~> 0.1"

Then run shards install.

Usage

require "pika-auth"

Include the module

class MyAPI < Pika::API
  include Pika::Auth
  # ...
end

include Pika::Auth installs a before hook that runs on every request. By itself it does nothing — you need to define at least one strategy.


Class-level auth

Use the auth macro to set a default strategy for the entire API class.

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    token == ENV["API_TOKEN"]
  end

  resource :users do
    get { User.all.to_json }
  end
end

Every request to every route in MyAPI must now supply a valid Bearer token.


Per-resource overrides

public_resource — no auth required

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    Token.valid?(token)
  end

  public_resource :health do
    get { {status: "ok"}.to_json }
  end

  resource :users do
    get { User.all.to_json }  # bearer required
  end
end

GET /health is completely open; all other routes still require a Bearer token.

resource_auth — different strategy per resource

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    UserToken.valid?(token)
  end

  resource_auth :webhooks, :api_key do |key|
    key == ENV["WEBHOOK_SECRET"]
  end do
    post { handle_webhook }
  end
end

POST /webhooks is authenticated with the api_key strategy; everything else uses the bearer default.


Strategies

BearerToken

Reads the token from Authorization: Bearer <token>.

auth :bearer do |token|
  Token.find_by(value: token) != nil
end

ApiKey

Reads the key from a header (default X-API-Key) and/or a query parameter. The header is checked first.

auth :api_key do |key|
  key == ENV["API_KEY"]
end

Custom header and/or query param (register manually):

Pika::Auth.register(
  :api_key,
  Pika::Auth::Strategies::ApiKey.new(
    validator:   ->(k : String) { k == ENV["API_KEY"] },
    header:      "X-My-Key",
    query_param: "key"
  )
)

Basic

Reads credentials from Authorization: Basic <base64(username:password)>.

auth :basic do |username, password|
  username == "admin" && password == ENV["ADMIN_PASSWORD"]
end

Error handling

All strategies raise Pika::UnauthorizedError on failure. Pika converts this to a 401 Unauthorized response with a JSON body by default:

{"error": "Unauthorized"}

Multiple strategies in one API

You can register several named strategies and mix them across resources:

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    UserToken.valid?(token)
  end

  resource_auth :internal, :api_key do |key|
    key == ENV["INTERNAL_KEY"]
  end do
    get { InternalData.all.to_json }
  end

  public_resource :health do
    get { "ok" }
  end

  resource :users do
    get { User.all.to_json }  # bearer (class default)
  end
end
Path Strategy
GET /health none (public)
GET /users bearer (class default)
GET /internal api_key (per-resource)

Patterns & recipes

Common patterns that come up when building real APIs. These are application-layer concerns — pika-auth keeps the validator block simple so you can plug in whichever approach fits your stack.

JWT with expiry

JWT tokens carry an exp claim that encodes the expiry time. Validate and check it inside the validator block. If you need a JWT shard, crystal-jwt is the standard choice.

require "jwt"

auth :bearer do |token|
  payload, _header = JWT.decode(token, ENV["JWT_SECRET"], JWT::Algorithm::HS256)
  payload["exp"].as_i > Time.utc.to_unix
rescue JWT::ExpiredSignatureError | JWT::VerificationError | JWT::DecodeError
  false
end

The rescue false pattern keeps the validator block self-contained — any invalid or tampered token returns false and pika-auth raises UnauthorizedError401.

To also carry user identity through to handlers, read claims from the token and stash them on the request:

auth :bearer do |token|
  payload, _ = JWT.decode(token, ENV["JWT_SECRET"], JWT::Algorithm::HS256)
  next false if payload["exp"].as_i <= Time.utc.to_unix
  env.request.headers["X-Current-User-Id"] = payload["sub"].as_s
  true
rescue JWT::ExpiredSignatureError | JWT::VerificationError | JWT::DecodeError
  false
end

resource :users do
  get do
    user_id = env.request.headers["X-Current-User-Id"]
    {id: user_id}.to_json
  end
end

Note: env is available inside the validator block via closure.

Opaque tokens with database lookup

For non-JWT tokens (random strings stored in a tokens table):

auth :bearer do |token|
  record = Token.find_by(value: token)
  record && record.expires_at > Time.utc && !record.revoked?
end

This pattern gives you full server-side control — you can revoke a token instantly by flipping a flag in the database, with no need to wait for a JWT to expire.

Token expiry and refresh flow

When a short-lived access token expires, clients receive a 401. The standard pattern is:

  1. Client stores a long-lived refresh token alongside the short-lived access token
  2. On 401, client sends the refresh token to a dedicated endpoint
  3. Server validates the refresh token, issues a new access token (and optionally rotates the refresh token)
  4. Client retries the original request with the new access token

In Pika, the refresh endpoint is a public_resource so it bypasses the auth check:

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    AccessToken.valid?(token)  # checks expiry, signature, revocation
  end

  # Public — no bearer token required to reach this
  public_resource :auth do
    desc "Exchange credentials for tokens"
    params do
      requires email    : String
      requires password : String
    end
    post do
      user = User.authenticate(declared_params.email, declared_params.password)
      raise Pika::UnauthorizedError.new unless user

      {
        access_token:  AccessToken.issue(user),   # short-lived, e.g. 15 minutes
        refresh_token: RefreshToken.issue(user),  # long-lived, e.g. 30 days
      }.to_json
    end

    desc "Refresh an access token"
    namespace :refresh do
      params do
        requires refresh_token : String
      end
      post do
        record = RefreshToken.find_valid(declared_params.refresh_token)
        raise Pika::UnauthorizedError.new unless record

        record.rotate!  # invalidate old refresh token, issue a new one

        {
          access_token:  AccessToken.issue(record.user),
          refresh_token: record.new_token,
        }.to_json
      end
    end
  end

  resource :users do
    get { User.all.to_json }  # bearer required
  end
end

Routes:

Route Auth Purpose
POST /auth none Login — returns access + refresh token
POST /auth/refresh none Exchange refresh token for new access token
GET /users bearer Protected resource

API key scoping

If your API keys need scope restrictions (e.g. a key that can only read, not write), check scopes inside the validator and raise Pika::ForbiddenError for insufficient permissions:

auth :api_key do |key|
  record = ApiKey.find_by(value: key)
  raise Pika::ForbiddenError.new unless record&.can_write?
  !record.nil?
end

Or register separate named strategies per scope and assign them with resource_auth:

auth :read_key do |key|
  ApiKey.find_by(value: key, scope: "read") != nil
end

# write_key strategy registered separately
Pika::Auth.register(:write_key, Pika::Auth::Strategies::ApiKey.new(
  ->(k : String) { ApiKey.find_by(value: k, scope: "write") != nil }
))

resource :reports do
  get { Report.all.to_json }   # read_key (class default)
end

resource_auth :reports, :write_key do |key|
  ApiKey.find_by(value: key, scope: "write") != nil
end do
  post { Report.create!(declared_params).to_json }
end

HTTP Basic for internal/admin routes only

A common pattern is using HTTP Basic for a small set of internal or admin endpoints while the main API uses Bearer tokens.

class MyAPI < Pika::API
  include Pika::Auth

  auth :bearer do |token|
    UserToken.valid?(token)
  end

  resource_auth :admin, :basic do |username, password|
    username == ENV["ADMIN_USER"] && password == ENV["ADMIN_PASSWORD"]
  end do
    get  { AdminStats.summary.to_json }
    post { AdminAction.run(declared_params) }
  end

  resource :users do
    get { User.all.to_json }  # bearer
  end
end

Webhook signature verification

Webhooks from third-party services (GitHub, Stripe, etc.) are typically verified by comparing an HMAC signature in a header against a shared secret, not by a Bearer token. Use resource_auth with a custom ApiKey strategy to read the signature header:

Pika::Auth.register(
  :github_webhook,
  Pika::Auth::Strategies::ApiKey.new(
    validator:   ->(sig : String) {
      expected = "sha256=" + OpenSSL::HMAC.hexdigest(:sha256, ENV["GITHUB_WEBHOOK_SECRET"], raw_body)
      Crypto::Subtle.constant_time_compare(sig, expected)
    },
    header:      "X-Hub-Signature-256",
    query_param: nil
  )
)

resource_auth :webhooks, :github_webhook do |sig|
  # validator already registered above — block here is the resource body
end do
  post { handle_github_event(env) }
end

Note: For webhook signature verification you need the raw request body before it is parsed. Store it on env in a before block and reference it in your validator closure.


License

MIT

Repository

pika-auth

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • about 3 hours ago
  • May 5, 2026
License

Links
Synced at

Tue, 05 May 2026 11:49:10 GMT

Languages