Skip to content
Ori Pekelman edited this page May 22, 2026 · 1 revision

Tep::Auth

Principal+delegate identity for tep apps. Apps configure one or more credential providers; every request gets a req.identity populated (anonymous if no provider matched), and handler code reads identity uniformly regardless of how the caller authenticated.

Battery 1 in docs/BATTERIES-DESIGN.md.

Identity model

class Tep::Identity
  attr_reader :principal_id   # String, opaque (apps own format)
  attr_reader :acting_via     # Tep::AgentDelegation or nil
  attr_reader :capabilities   # Array of Symbols

  def human?    ; @acting_via == nil end
  def agent?    ; !human?            end
  def may?(cap) ; @capabilities.include?(cap) end
  def subject   ; ... end  # "user:42" for humans;
                           # "agent:bot/user:42" for agents
end

class Tep::AgentDelegation
  attr_reader :agent_id, :issued_at, :expires_at, :origin
end

req.identity is always non-nil. Anonymous requests get Tep::Identity.anonymous (empty principal_id, empty caps, no delegation). Handlers gate via if req.identity.may?(:write) or if req.identity.human?.

Providers

Configure once at boot, install the filter, done:

require 'sinatra'

Tep::AuthBearerToken.set_secret(ENV["JWT_SECRET"])
Tep.session_secret = ENV["TEP_SESSION_SECRET"]

Tep::Auth.install!

get '/me' do
  res.headers["Content-Type"] = "text/plain"
  req.identity.subject
end

Three providers ship, chained in fixed order — first match wins:

  1. Tep::AuthBearerTokenAuthorization: Bearer <JWT>. HS256.

  2. Tep::AuthSessionCookie — reads identity from req.session (the existing tep.session signed cookie). Apps write the identity on login + clear on logout:

    post '/login' do
      # ... password / OAuth check ...
      ident = Tep::Identity.new("user:42", nil, [:read, :write])
      Tep::AuthSessionCookie.set(req, ident, 0)   # 0 = no exp
      ""
    end
    
    post '/logout' do
      Tep::AuthSessionCookie.clear(req)
      ""
    end
  3. Tep::AuthOAuth2 — authorization-code grant issuance (tep is the auth server here, not a client). Used for bots / agents acting on behalf of a human. See the section below.

The provider chain order is hardcoded (spinel's PtrArray<Base> dispatch can't carry cls_id reliably). To skip a provider, don't configure its secret — Bearer with no secret returns nil from try, falling through to the next.

JWT wire format

Flat single-level JSON (matches Tep::Json's flat-object extractors):

{
  "sub":      "user:42",
  "exp":      1716396000,
  "caps":     "read,write,post_summary",
  "delegate": "summarizer-bot|1716392400|1716396000|token"
}

delegate is pipe-encoded agent_id|issued_at|expires_at|origin — optional. When present, req.identity.agent? is true and req.identity.acting_via.agent_id is the agent's id.

OAuth2 / delegated grant issuance

For bots / agents getting tokens on behalf of a human:

Tep::AuthOAuth2.register_client(
  "summarizer-bot",
  "Summarizer Bot",
  "https://bot.example/oauth/callback",
  [:read, :post_summary])

# In your /authorize route, after consent UI + user clicks Allow:
code = Tep::AuthOAuth2.issue_code(
  req.identity.principal_id,
  "summarizer-bot",
  "read,post_summary",
  0)   # 0 = DEFAULT_CODE_TTL (600s)

# Bot's /token route redeems for a JWT:
token = Tep::AuthOAuth2.exchange_code(
  params[:code], params[:client_id], 0)
# Bot uses `Authorization: Bearer <token>` from here.

The exchanged JWT carries delegate populated with agent_id=<client_id>, origin=:oauth_grant. Downstream identity surface is identical — the bot's req.identity.agent? is true, acting_via.agent_id is the client_id.

Capabilities

Tep::Auth::CORE_CAPABILITIES = [:read, :write, :authn, :authz]. Apps register their own via Tep::Auth.register_capability(:foo) (planned follow-up; today caps are just symbols apps choose).

The granted cap set on a token is always a subset of the principal's caps — the issuer enforces it at consent time. may?(cap) checks the set; apps gate authz:

get '/admin' do
  raise "denied" unless req.identity.may?(:authz)
  # ...
end

Filter order

Tep::Auth.install! registers Tep::AuthFilter in a dedicated @auth_filter slot on Tep::App — runs before the user's @before_filter. So user filters and handlers always see a populated req.identity.

Clone this wiki locally