Authentication¶
A session is a signed JWT, sitting in a secure, httponly cookie. Its only field rel
actually cares about is role — the Postgres role every query and route call in that session
runs as, applied with SET ROLE/SET LOCAL ROLE on the connection. Authentication in rel is
really just "how does the right value end up in that one claim" — there's no separate
authorization layer to configure on top; once role is set, Postgres's own grants and
row-level security decide what the request can actually do.
Anonymous access works the same way: with no valid session, a request runs as
pg.query.anonymous_role (default ~anonymous) — a real Postgres role that must exist in the
database, or anonymous access is disabled outright and every unauthenticated request gets
401. There's no implicit "public" state in between.
Minting a session¶
Any full-control route or middleware function (the two-OUT-column shape — see [HTTP routes
Function prototype](index.md#function-prototype)) can start a session by setting a jwt¶
field on its response:
create function auth.login(req jsonb, out resp jsonb, out content jsonb)
returns record
language plpgsql
security definer
as $$
declare
matched_user auth.users;
begin
select * into matched_user from auth.users
where auth.users.username = (req->'body')->>'username'
and auth.users.password_hash = crypt((req->'body')->>'password', auth.users.password_hash);
if matched_user is null then
raise exception 'Invalid credentials' using errcode = 'RS401';
end if;
resp := jsonb_build_object('jwt', jsonb_build_object(
'role', matched_user.role,
'user_id', matched_user.id,
'plan', matched_user.plan
));
content := null;
end;
$$;
comment on function auth.login(jsonb) is 'route:: path: "/auth/login", method: "POST"';
Called with {"username": "...", "password": "..."} as the JSON body. (Naming the path
/auth/login here is just an example, not special — actual /auth/* is a reserved prefix
rel itself serves for OIDC/SAML, and a user-declared route can never sit there; a real login
function lives at whatever path you choose.)
role is the only claim rel itself reads back out of the JWT (to SET ROLE with); every other
key in the object — user_id/plan above, or anything else — is just carried along as a
custom claim, verbatim, for your own functions to read later via req.jwt, or via a
session-revocation middleware (see Session
lifecycle below). rel fills in iat, exp, and auth_time itself;
setting either of those, or auth_time, on the response's jwt object has no effect —
they're always overwritten. Setting jwt: null on a response clears the session (logout).
By default, any route or middleware function can set jwt and authenticate the caller as
any role; restrict that with http.functions.allowed_auth (a regexp against the function's
fully qualified name) once you have real login functions to point it at — e.g. ^auth\.. A
terminating middleware setting jwt is gated by its own identifier; a pass-through
middleware setting jwt ahead of a route is gated by that route's identifier instead (the
route's own response is what actually renders) — and ahead of /rel or a static file, by no
identifier at all, which allowed_auth can only ever match by being left unrestricted. Have
a middleware gating those two paths terminate, rather than merely pass jwt through, if
allowed_auth is restricted.
OpenID Connect and SAML¶
For OIDC and SAML, rel drives the protocol itself — you only write the function that turns a verified identity assertion into a role. Configure one named entry per provider:
[openid.google]
issuer = "https://accounts.google.com"
# client_id / client_secret default to /secrets/openid-google.id and
# /secrets/openid-google.secret — drop the two files there and skip these lines
callback_function = "auth.sso_callback"
[saml.corp_okta]
idp_metadata_url = "https://corp.okta.com/app/.../sso/saml/metadata"
callback_function = "auth.sso_callback"
<name> (google, corp_okta above) is yours to choose — it's not tied to any provider's
brand, so two Google Workspace tenants, or an OIDC and a SAML connection to the same IdP, are
just two differently-named entries. Each one gets its own routes:
GET /auth/oidc/{name}/login/GET /auth/oidc/{name}/callbackGET /auth/saml/{name}/login/POST /auth/saml/{name}/acsGET /auth/saml/{name}/metadata— this deployment's SP metadata, to hand to the IdP admin
Both protocols converge on the same callback function signature — one payload argument,
returning a single jsonb value shaped like HttpResponse
plus its own content/template_data keys (this one call site predates, and isn't a
declared route itself, so it keeps its own single-envelope shape rather than the two-OUT-column
one) — regardless of which protocol produced it:
create function auth.sso_callback(payload jsonb) returns jsonb
language plpgsql
security definer
as $$
declare
matched_role text;
begin
-- payload.identity.protocol is 'oidc' or 'saml'; payload.identity.claims
-- holds the ID token's claims (OIDC) or every assertion attribute (SAML,
-- each value a string[] even when single-valued). Look up whatever
-- identifies this user in your own schema and decide a role from it.
select role into matched_role
from auth.users where auth.users.email = payload->'identity'->'claims'->>'email';
if matched_role is null then
raise exception 'No account for this identity' using errcode = 'RS401';
end if;
return jsonb_build_object('jwt', jsonb_build_object('role', matched_role));
end;
$$;
payload is shaped:
interface SsoCallback {
jwt: JWT | null // the browser's OWN current session, if any — see below
identity: {
protocol: "oidc" | "saml"
name: string // the configured openid.<name>/saml.<name> entry
claims: { [key: string]: unknown }
access_token?: string // OIDC only, when the token exchange returned one
refresh_token?: string
}
state: unknown // /login's own query string, decoded — see below
}
rel doesn't interpret an email, a groups claim, or a SAML attribute for you — mapping
identity to a role is entirely your call, which is what keeps rel from assuming any particular
user-table shape. openid.<name>.callback_function/saml.<name>.callback_function each fall
back to http.functions.sso_callback when unset, so one function can serve every provider if
your role-mapping logic doesn't need to vary per provider.
The browser's own current session : jwt¶
payload.jwt is whatever session the browser already had before this SSO round trip started
— read directly off the callback request itself, the exact same way any other authenticated
request's session is, never sent to or through the identity provider. This is what makes
account linking possible: a callback function can tell "this browser is already logged in as
user X, and just finished SSO as identity Y" (payload.jwt non-null) apart from a fresh,
anonymous login (payload.jwt null), and decide to link the two rather than always minting a
brand-new session.
One thing worth knowing before relying on it: payload.jwt reliably arrives for OIDC's
callback (a GET, and rel's JWT cookie is SameSite=Lax by default — which does survive a
cross-site top-level GET navigation), but not for SAML's /acs under the same default —
/acs is always a POST, and SameSite=Lax cookies aren't sent on a cross-site POST. A
callback function driven by SAML will see payload.jwt: null even for an actually-logged-in
browser, unless the deployment sets jwt.same_site = None.
Errors¶
Two distinct error sources exist on /auth/oidc/* and /auth/saml/*, and a client tells them
apart the same way it tells apart any other code/X-Rel-Errorcode pair (see Error
responses) — not by HTTP status alone, since
both can plausibly render as a 4xx or 5xx:
- The callback function's own rejection —
raise exception ... using errcode = 'RSxxx', the same convention any other route function uses, once a request has actually reached the database. - Protocol-level failures rel itself detects, before the callback function ever runs — these get their own rel-internal codes:
code |
Status | Meaning |
|---|---|---|
SSO_NOT_READY |
503 | discovery/IdP metadata hasn't resolved yet |
SSO_BAD_REQUEST |
400 | malformed callback request — missing code, unparseable form |
SSO_BAD_STATE |
400 | missing or mismatched OAuth2 state |
SSO_BAD_NONCE |
400 | ID token nonce doesn't match the one this login minted |
SSO_TOKEN_EXCHANGE_FAILED |
502 | the issuer's token endpoint rejected the exchange |
SSO_NO_ID_TOKEN |
502 | token response carried no id_token |
SSO_INVALID_ID_TOKEN |
502 | id_token failed signature/issuer/audience verification |
SSO_USERINFO_FAILED |
502 | fetch_userinfo's own call to the issuer failed |
SSO_SAML_INVALID_RESPONSE |
400 | SAML response/assertion failed to parse or verify |
SSO_INTERNAL |
500 | rel's own logic failed — state/nonce generation, encoding |
Passing state through login¶
/login accepts a plain query string, decoded the same structural way GET
/rel's own query string is (dotted keys nest), and handed
back to the callback function verbatim as payload.state — null if /login got no query
string at all. A link to /auth/oidc/google/login?return_to=/dashboard becomes
payload.state = {"return_to": "/dashboard"} by the time the callback function runs, round-tripped
through whichever mechanism the protocol itself provides for exactly this (OIDC's state
parameter, alongside rel's own CSRF token ; SAML's RelayState, otherwise unused).
Treat state as untrusted input, the same as any query string a client controls. Nothing
stops a third party from linking a victim straight to /login?... with a query string of their
own choosing — this endpoint is unauthenticated and was never meant to be secret. Two
consequences:
- Never redirect to a
state-supplied URL without validating it first. Areturn_to-style value is the obvious thing to put instate, and blindly redirecting to whatever it says is a textbook open redirect — restrict it to a relative path, or an allowlist of known hosts, before using it. - Never put a secret in
state. Both OIDC'sstateand SAML's RelayState round-trip through the identity provider itself, and are visible in redirect URLs, browser history, and IdP-side logs along the way — treat it exactly as visible as anything else the browser's address bar shows.
A few things worth knowing before wiring this up in production:
http.public_hostmust be set — a bare host likeapp.example.com, no scheme — so rel can build the exactredirect_uri/ACS URL each provider needs registered ahead of time.- SAML needs a stable certificate. rel generates a self-signed one on first boot if
saml.certificate_path/saml.private_key_pathdon't already exist, and reuses it silently on every later boot — swapping it breaks every IdP that was told to trust the old one, so back those files up the same way you'd back up any other credential. That one certificate is shared across everysaml.<name>entry by default; an entry needing an IdP to trust a certificate of its own setssaml.<name>.certificate_path/private_key_path— same generate-if-missing behavior, scoped to that entry alone. - A misconfigured or not-yet-reachable provider doesn't fail startup — rel logs a warning and
serves
503from that provider's endpoints until it becomes reachable, so one broken IdP connection doesn't take the rest of the deployment down.
Session lifecycle¶
A verified token gets renewed automatically once more than jwt.renew_after (default half)
of its own lifespan has elapsed — role and auth_time never change on renewal, only
iat/exp. Two separate limits bound a session: jwt.max_age bounds any single token (short,
so a stolen cookie is only useful briefly), and jwt.max_session_age bounds how long renewal
can keep extending the session overall, measured from the original auth_time — once it's
exceeded, the session can't be renewed anymore and the user has to fully re-authenticate.
For revocation before either of those would naturally expire it — a password change, a ban, an
admin-triggered logout — declare a session-checking middleware at
whatever prefix needs it (/ to cover everything, including /rel). Unlike the old
check_session mechanism, middleware runs after the role switch, as the request's own
already-resolved role — and terminates with {status: 401, jwt: null} to reject and clear
the session in one step, rather than merely raising:
create function auth.check_session(req jsonb, out resp jsonb, out content jsonb)
returns record
language plpgsql
security definer
as $$
begin
if exists (select 1 from auth.revoked_sessions where user_id = (req->'jwt'->>'user_id')::bigint) then
resp := jsonb_build_object('status', 401, 'jwt', null);
else
resp := null;
end if;
content := null;
end;
$$;
comment on function auth.check_session(jsonb) is 'route:: path: "/", middleware: true';
Terminating ({status: 401, jwt: null}) clears the session and forces re-authentication;
returning resp := null (a genuine SQL NULL, not the JSON literal) lets the request proceed
unchanged. rel doesn't require any particular claim (a jti, a session id) for this to work —
how you identify "this session" in your own revoked-sessions table is up to whatever custom
claims your login function put on the JWT. Since this middleware runs as the request's own
role rather than the primary connection, grant it EXECUTE for every role that should reach
anything under its prefix, including the anonymous role — see Deployment
checklist below.
Claims as a Postgres setting¶
The claims object is also available as a plain Postgres setting, current_setting('rel.jwt.claims',
true)::jsonb — the same shape as req.jwt (null for an anonymous request), readable from
any function that runs as part of handling the request: an RLS policy, a trigger, a function
called deeper down that doesn't have the claims threaded through as an argument. It's set
right alongside the role switch, so it's there for every route/middleware function and every
/rel query/write.
create policy own_rows_only on documents
using (owner_id = (current_setting('rel.jwt.claims', true)::jsonb->>'user_id')::bigint);
The true second argument matters: without it, current_setting raises an error instead of
returning null on a connection where it was never set — worth keeping even though rel itself
always sets it, since a direct psql session (superuser, migrations) never goes through this
path at all.
Deployment checklist¶
SET ROLE only succeeds when the connecting role is already a member of the role being
switched to — grant it explicitly for every role any JWT in the deployment may carry,
including the anonymous one:
That grants rel_user (pg.user — see Best practices ## Keep pg.user's own privileges
minimal)
membership — the ability to SET ROLE into ~anonymous at all. A route function still
needs its own EXECUTE grant, directly to ~anonymous (or to a role it belongs to), before an
anonymous request can reach it:
Skipping a grant on a route doesn't fail at startup : rel caches, once per discovered route
at introspection/reload time, whether the anonymous role can actually reach it (see below), and
rejects an anonymous request the anonymous role can't reach with 401 before the request body
is even read. A middleware function has no such precheck, since it runs as the request's
already-resolved role rather than pg.user itself — a missing EXECUTE grant there surfaces as
an ordinary Postgres permission-denied 403 on the first real request that reaches it, the
same as any other route call missing a grant.
When anonymous access is enabled, rel caches, once per discovered route or middleware at
introspection/reload time, whether the anonymous role can actually reach it — schema USAGE
plus an explicit EXECUTE grant, to the anonymous role itself or to a role it belongs to.
(That cache backs the route-level 401 precheck above; middleware, as noted, isn't
precomputed this way, since middleware permission failures show up as 403s instead.)
This check deliberately doesn't credit EXECUTE the moment it's merely inherited from a grant
to PUBLIC — Postgres grants EXECUTE to PUBLIC by default on create function unless
default privileges were changed, so crediting it here would silently authorize anonymous
access to any route function nobody explicitly decided the anonymous role should reach. Grant
EXECUTE on any route function you actually want the anonymous role to call, the same way you
grant it any other role. Independently of this check, rel also warns (non-fatally) at
introspection/reload for every route or middleware reachable by PUBLIC at all, regardless of
caller — see Best practices for turning that default off
entirely.
Configuration reference¶
| Key | Default | Purpose |
|---|---|---|
jwt.secret |
generated into /secrets/jwt-secret |
JWT signing secret |
jwt.cookie_name |
accesstoken |
cookie carrying the JWT |
jwt.algorithm |
HS256 |
one of HS256/HS384/HS512 |
jwt.same_site |
Lax |
SameSite on the JWT cookie |
jwt.max_age |
1800 (30 min) |
freshly-minted token lifetime, seconds |
jwt.renew_after |
0.5 |
fraction of lifetime elapsed before auto-renewal |
jwt.max_session_age |
604800 (7 days) |
hard ceiling on total session lifetime |
pg.query.anonymous_role |
~anonymous |
role for requests with no valid session |
http.functions.allowed_auth |
unset (unrestricted) | regexp restricting which functions may set jwt |
http.public_host |
unset | this deployment's externally-reachable host, required for OIDC/SAML |
openid.<name>.issuer |
— | required; the OIDC issuer URL |
openid.<name>.scopes |
openid, email, profile |
requested OIDC scopes |
openid.<name>.fetch_userinfo |
false |
also call the userinfo endpoint after token exchange |
openid.<name>.callback_function / saml.<name>.callback_function |
unset, falls back to http.functions.sso_callback |
function invoked with the verified claims |
saml.<name>.idp_metadata_url |
— | required; fetched once at startup |
saml.<name>.force_signed_requests |
true |
sign outgoing AuthnRequests |
saml.certificate_path / saml.private_key_path |
generated on first boot | this deployment's SP certificate/key, shared across every saml.<name> entry |
saml.<name>.certificate_path / saml.<name>.private_key_path |
unset, inherits the shared pair above | per-entry override, generated on first boot the same way if set and missing |
See Configuration reference for how these values, secrets included, get
supplied across environment variables, config files, and generated files — including a couple
of keys not repeated here (openid.<name>.client_id/client_secret,
openid.<name>.public_host/saml.<name>.public_host) and every other key rel understands.