A Ruby client for the Neon API, with first-class Neon Auth support for Ruby on Rails apps.
This is the Ruby counterpart to the official
neon-api-python client. It
mirrors that library's management API surface (projects, branches, API keys, …)
and goes further by wrapping the Neon Auth endpoints — enabling the
integration, configuring OAuth providers, managing users, signing users in
server-side, and verifying Neon Auth JWTs at runtime — so you can drop Neon Auth
into a Rails app.
Status:
0.1.0. The authentication surface is the priority and is implemented and tested; the broader management API is being filled in to stay in sync with the Neon OpenAPI spec.
- Installation
- Quick start
- Authentication & security
- Neon Auth
- Rails sign-in (server-side)
- Management API
- Error handling
- Calling endpoints that aren't wrapped yet
- Development
- Roadmap
- License
Add it to your Gemfile:
gem "neon-api"Then:
bundle installOr install it directly:
gem install neon-apiEd25519 / EdDSA note: Neon Auth signs JWTs with EdDSA (Ed25519) by default. To verify those tokens locally with
NeonAPI::Auth::JWTVerifier, add therbnaclgem (which thejwtgem uses for Ed25519):gem "rbnacl", "~> 7.1"Projects configured for RS256 need no extra dependency.
require "neon_api"
# Reads NEON_API_KEY from the environment
client = NeonAPI.from_environ
# ...or pass a key explicitly
client = NeonAPI.new(api_key: "neon_api_key_...")
client.me.email #=> "[email protected]"
client.projects.projects #=> [#<NeonAPI::Object ...>, ...]Every response is a NeonAPI::Object — a thin wrapper
that supports both obj["key"] and obj.key access, and to_h for the raw
hash.
The client authenticates to the Neon API with a Neon API key, sent as a bearer token. Your API key grants access to sensitive data — never commit it to source control or expose it client-side. Prefer an environment variable:
client = NeonAPI.from_environ # reads NEON_API_KEY
client = NeonAPI.from_environ(env: "MY_KEY")Create a key in the Neon Console under Account settings → API keys.
Neon Auth is Neon's managed authentication. It issues standards-based JWTs, syncs users into your database, and supports OAuth providers (Google, GitHub, Microsoft, Vercel). Integrations are scoped to a project + branch — most apps use the project's default branch.
Get the auth surface for a branch:
auth = client.auth(project_id, branch_id)integration = auth.enable(auth_provider: "better_auth")
integration.jwks_url #=> "https://.../.well-known/jwks.json"
integration.pub_client_key #=> publishable client key (safe for the frontend)
integration.secret_server_key #=> SECRET — store securely, shown only once
integration.schema_name #=> "neon_auth"
integration.base_url #=> hosted auth base URL
pub_client_key,secret_server_key,schema_name, andtable_nameare returned byenableonly —auth.configreturns a smaller set (no keys, no schema/table). Access maybe-absent fields withintegration["schema_name"]orintegration.to_h, which returnnilrather than raising. On the currentbetter_authbackend, the synced identity table isneon_auth.user(see Manage users);users_syncis the legacy Stack Auth name.
Inspect or update it later:
auth.config # GET current configuration
auth.update(name: "My App") # PATCH settings
auth.disable # remove the integration (keeps synced data)
auth.disable(delete_data: true)This is what you wire into Rails OmniAuth. Configure a provider with your own OAuth credentials (in production — omit them to use Neon's shared dev keys):
providers = auth.oauth_providers
providers.add(id: "google", client_id: ENV["GOOGLE_CLIENT_ID"],
client_secret: ENV["GOOGLE_CLIENT_SECRET"])
providers.add(id: "github", client_id: "...", client_secret: "...")
providers.list # all configured providers
providers.update("google", client_secret: "...") # rotate a secret
providers.delete("github")Supported provider ids: google, github, microsoft, vercel
(NeonAPI::Auth::OAuthProviders::SUPPORTED). Microsoft accepts an optional
microsoft_tenant_id:.
users = auth.users
users.create(email: "[email protected]", password: "s3cret", display_name: "Ada")
users.set_role(user_id, role: "admin")
users.update(user_id, display_name: "Ada L.")
users.delete(user_id)Neon Auth also syncs users into neon_auth.user in your database, so for reads
you can query that table directly from Rails. Note Better Auth's columns are
camelCase (emailVerified, createdAt, ...):
SELECT id, email, name FROM neon_auth.user;Your Rails app receives a Neon Auth JWT (typically in Authorization: Bearer <token>) and verifies it against the project's JWKS endpoint:
verifier = NeonAPI::Auth::JWTVerifier.new(jwks_url: ENV.fetch("NEON_AUTH_JWKS_URL"))
# or derive it from the integration:
verifier = auth.jwt_verifier(jwks_url: integration.jwks_url)
claims = verifier.verify(token)
claims.sub #=> Neon Auth user id (matches neon_auth.user.id)
claims.email
claims.role #=> "authenticated"The verifier checks the signature, expiry, and (optionally) issuer/audience;
caches the JWKS; and automatically refreshes once on key rotation. It raises
NeonAPI::Auth::TokenExpiredError / NeonAPI::Auth::InvalidTokenError on
failure, or use verify? to get nil instead:
if (claims = verifier.verify?(token))
current_user = User.find_by(neon_auth_id: claims.sub)
endSee docs/neon_auth.md for the full reference.
Managed Neon Auth (the better_auth backend) is not an OIDC provider — it
exposes no /authorize, OIDC /token, /userinfo, or discovery document, so an
omniauth_openid_connect relying-party flow can't complete against it. The path
that works for a server-rendered Rails app is Better Auth's server-side REST API,
wrapped by NeonAPI::Auth::BetterAuthClient: sign the user in, exchange the
session for a JWT, then verify it.
class SessionsController < ApplicationController
def create
ba = NEON_AUTH.better_auth # base_url pulled from the integration
ba.sign_in_email(email: params[:email], password: params[:password])
claims = NEON_AUTH_VERIFIER.verify(ba.token) # the EdDSA JWT
user = User.find_or_create_by!(neon_auth_id: claims.sub) do |u|
u.email = claims.email
end
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in"
rescue NeonAPI::AuthenticationError
redirect_to login_path, alert: "Invalid email or password"
end
endBetterAuthClient handles the required Origin header and keeps a cookie jar, so
sign-in → token works on one instance; persist ba.session_cookie to resume a
session across requests. It wraps sign_up_email, sign_in_email, get_session,
token, and sign_out.
For server-side social login, NeonAPI::Auth::SocialAuth (via auth.social)
initiates the provider flow and redeems the callback. Managed Neon Auth hands the
result back as a one-time neon_auth_session_verifier, and redeeming it also needs
the challenge that Neon sets at initiation — so you stash the challenge (e.g. in
the Rails session) and pass it back on the callback. No Node sidecar, no cookie
secret:
class NeonSocialController < ApplicationController
# GET /auth/neon/start
def start
init = neon_social.sign_in(provider: "google",
callback_url: neon_callback_url)
session[:neon_challenge] = init.challenge
redirect_to init.url, allow_other_host: true
end
# GET /auth/neon/callback?neon_auth_session_verifier=...
def callback
result = neon_social.redeem_callback(
verifier: params[:neon_auth_session_verifier],
challenge: session.delete(:neon_challenge)
)
claims = NEON_AUTH_VERIFIER.verify(result.jwt)
user = User.find_or_create_by!(neon_auth_id: claims.sub) { |u| u.email = claims.email }
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in with Google"
rescue NeonAPI::Auth::SocialAuthError
redirect_to login_path, alert: "Sign-in could not be completed"
end
private
def neon_social = NEON_AUTH.social
endThe
callback_urlhost must be allow-listed in the Neon Console (Auth → Configuration → Domains), or the browser redirect after consent won't reach your app. Configure the Google provider withauth.oauth_providers.add(id: "google", client_id:, client_secret:)for production (the shared dev keys show Neon's consent screen).
Don't want to write the two routes yourself? NeonAPI::Auth::RackHandler is a
Rack app that serves the start and callback routes for you; you provide a
block that maps the verified identity to a local user:
# config/initializers/neon_auth.rb
NEON_SOCIAL_HANDLER = NeonAPI::Auth::RackHandler.new(
social: NEON_AUTH.social,
verifier: NEON_AUTH_VERIFIER,
callback_url: "https://app.example.com/auth/neon/callback"
) do |success|
user = User.find_or_create_by!(neon_auth_id: success.claims.sub) { |u| u.email = success.claims.email }
success.request.session[:user_id] = user.id
"/" # redirect target on success
end
# config/routes.rb
mount NEON_SOCIAL_HANDLER => "/auth/neon" # → /auth/neon/start and /auth/neon/callbackIt stashes the challenge in the Rack session between the two requests (so a
session middleware is required — Rails has one) and loads rack lazily.
If you're on Rails and want flash, route helpers, and a request-derived
callback_url (things a mounted Rack app gives up), configure Neon Auth once and
include a controller concern. The gem loads this layer only when Rails is present
— the core stays framework-agnostic.
# config/initializers/neon_auth.rb
NeonAPI::Auth.configure do |c|
c.base_url = ENV["NEON_AUTH_BASE_URL"] # jwks_url derived unless set
c.enabled = c.base_url.present? && !Rails.env.test?
c.find_user { |claims| User.find_or_create_from_neon_claims(claims) } # the one app seam
endThis gives you derived accessors — NeonAPI::Auth.enabled?, .verifier,
.social, .better_auth — so apps stop hand-rolling that glue.
Thread-safety:
.verifieris memoized and safe to share (verification is read-only)..socialand.better_authreturn a fresh client each call — they carry a mutable cookie jar that must not be shared across threads — so use one per request/call (building does no network). TheControllerconcern already does this for you.
class NeonSessionsController < ApplicationController
include NeonAPI::Auth::Controller
neon_auth callback_url: ->(_req) { neon_callback_url }, # request-derived; no APP_BASE_URL
on_success: ->(claims) {
sign_in(neon_find_user(claims))
redirect_to root_path, notice: "Signed in with Google." # flash works
},
on_failure: ->(error) { redirect_to login_path, alert: "Sign-in failed." }
end
# config/routes.rb
get "/auth/neon/start", to: "neon_sessions#neon_social_start"
get "/auth/neon/callback", to: "neon_sessions#neon_social_callback"The concern runs the challenge-stash / redeem / verify plumbing; your controller
keeps routes, helpers, flash, and session policy. The Neon clients are reached
through overridable methods (neon_social, neon_verifier), so request specs
stub those seams instead of any_instance. A generator scaffolds the initializer
and a neon_auth_id migration:
bin/rails g neon_auth:installThe full walkthrough — routes, controllers, JWT-protected API requests, and the (self-hosted-only) OIDC helper — is in docs/rails_omniauth.md.
NeonAPI::OmniAuth.openid_connect_optionsremains for a self-hosted Better Auth deployment that runs the (non-managed) oidc-provider plugin, or another real OIDC provider fronting Neon. It does not work against managed Neon Auth.
Mirrors the Python client. Currently wrapped:
client.me
client.api_keys
client.api_key_create("ci")
client.api_key_revoke(key_id)
client.projects(limit: 10)
client.project(project_id)
client.project_create(project: { name: "Prod" })
client.project_update(project_id, project: { name: "Prod 2" })
client.project_delete(project_id)
client.branches(project_id)
client.branch(project_id, branch_id)
client.branch_create(project_id, branch: { name: "feature-x" })More endpoints (databases, endpoints, roles, operations, consumption) are on the roadmap. Until they're wrapped, see below.
Non-2xx responses raise a subclass of NeonAPI::APIError, so you can rescue
broadly or specifically:
begin
client.project("does-not-exist")
rescue NeonAPI::NotFoundError => e
e.status #=> 404
e.body #=> parsed error body
e.request #=> "GET /projects/does-not-exist"
e.request_id #=> Neon's request id, handy for support
end| Status | Error class |
|---|---|
| 400 | NeonAPI::BadRequestError |
| 401 | NeonAPI::AuthenticationError |
| 403 | NeonAPI::ForbiddenError |
| 404 | NeonAPI::NotFoundError |
| 409 | NeonAPI::ConflictError |
| 422 | NeonAPI::UnprocessableEntityError |
| 429 | NeonAPI::RateLimitError |
| 5xx | NeonAPI::ServerError |
All inherit from NeonAPI::APIError < NeonAPI::Error.
The underlying connection is public, so you can reach any endpoint:
client.connection.get("projects/#{id}/operations")
client.connection.post("projects/#{id}/branches/#{bid}/auth/send_test_email")On the host (needs a local Ruby toolchain):
bin/setup # or: bundle install
bundle exec rspec # run the tests (fully mocked, no network)
bundle exec rubocop # lint
bundle exec rake # bothOr do everything in Docker — no system Ruby required, just Docker and
just:
just up # build the image and start the container
just test # run the tests
just lint # run RuboCop
just check # spec + rubocop (the default rake task)
just console # IRB with the gem loadedSee docs/docker.md for the full command list and notes.
Tests use WebMock so they never touch the network — the equivalent of the Python client's VCR cassettes.
- Authenticated client foundation (
from_environ/from_token) - Neon Auth: enable / config / update / disable
- Neon Auth: OAuth providers CRUD
- Neon Auth: users
- Runtime JWT verification (JWKS, caching, rotation)
- OmniAuth / OIDC config helper
- Management: me, api keys, projects, branches
- Management: databases, endpoints, roles, operations, consumption
- Generated schema/type objects from the OpenAPI spec
- Published to RubyGems
Apache-2.0, matching the upstream neon-api-python client.
This is an independent, community-built client and is not an official Neon product.