Skip to main content
Version: 1.32.0

Backends & federated sign-in

Authentication backends answer one question — do these credentials belong to a real user — and are consulted as an ordered chain. Federated identity providers (OIDC, SAML) are a separate contract, because there are no credentials to hand to a chain. This page covers both.

Authenticating against a directory​

Authentication backends are selected by name, so a backend lives in its own module and you import it. The framework itself carries no LDAP client, no SAML service provider and no OIDC client — an application that does not authenticate against any of them should not download them.

LDAP​

LDAP ships with Nucleus, as a separate module:

go get github.com/jcsvwinston/nucleus/providers/ldap

Import it for its side effect — the shape database/sql drivers use — and name it in the chain:

import _ "github.com/jcsvwinston/nucleus/providers/ldap"
# nucleus.yml
auth_backends: [ldap, local]

auth:
ldap:
url: "ldaps://dc.corp.local:636"
base_dn: "ou=people,dc=corp,dc=local"
bind_dn: "cn=svc-nucleus,ou=services,dc=corp,dc=local"
bind_password: "${LDAP_BIND_PASSWORD}"

Every setting the backend takes is documented in its README, along with what it refuses to do — an empty password is rejected before a connection is opened, a username is filter-escaped before it reaches the query, an ambiguous search is a rejection rather than a coin toss, and only a directory that actually says "wrong credentials" produces a rejection.

Naming a backend you have not imported fails at boot with the two lines that fix it — the go get and the import. It is not left for the first person who tries to log in.

Anything else​

The same seam is open to you:

package acmeauth

import "github.com/jcsvwinston/nucleus/pkg/auth/backend"

func init() {
backend.Register("acme", New)
}

Check your backend against the contract​

The contract has parts that are easy to get wrong and expensive to get wrong. Point the conformance suite at your backend and it will tell you:

func TestConformance(t *testing.T) {
backendtest.Run(t, backendtest.Suite{
New: func() (backend.Backend, error) { return New(cfg) },
ValidUser: "ana",
ValidPassword: "correcta",
UnknownUser: "nadie",
// Optional, and the most valuable: the same backend pointed at a
// source it cannot reach. Only you know how to break your own
// connection.
Unavailable: func() (backend.Backend, error) { return New(deadCfg) },
})
}

It checks that a wrong password and an unknown user produce the same rejection — a caller that can tell them apart can enumerate your users — that an empty password is refused, and that an unreachable source reports unavailable rather than a rejection, which is what keeps the chain falling through to the local account someone needs on the morning the directory is down.

It checks the contract, not the behaviour: passing does not mean your backend talks to the right directory.

Import pkg/auth/backend, not pkg/auth. It is a leaf package holding the contract and nothing else — the interface, the identity it returns, the two sentinel errors, the configuration subtree and the registry — so writing a backend does not drag in session stores, JWT, Redis, Prometheus and OpenTelemetry to implement two methods. The names remain available from pkg/auth as aliases, so existing code keeps compiling.

A backend answers one question — do these credentials belong to a real user — and returns one of three things: the user, ErrInvalidCredentials when the answer is certainly no, or ErrBackendUnavailable when it could not reach the directory at all.

That third case is why backends are consulted as an ordered chain rather than a set:

auth_backends: [ldap, local]

The directory answers first; the local user table answers second. When the directory is unreachable, the chain records it and moves on — so the break-glass account you keep for exactly that morning still works. A set could not express that, and neither could a single configured backend.

The distinction survives to the end. If every backend rejected, the caller gets "invalid credentials", because that is what happened. If any backend was unreachable, the caller gets an error saying which — "wrong password" and "the directory is down" send an operator to very different places, and guessing between them wastes the hour that matters.

Each backend reads its own settings​

A backend named in auth_backends owns the auth.<name>.* subtree. The framework validates only that the section belongs to a registered name and hands the contents over; the backend declares their shape and validates them:

func New(bc auth.BackendConfig) (auth.Backend, error) {
var cfg struct {
URL string `koanf:"url" validate:"required"`
Timeout time.Duration `koanf:"timeout" default:"5s"`
}
if err := bc.Bind(&cfg); err != nil {
return nil, err
}
// …
}

Two things fail rather than pass quietly:

  • A key the backend does not declare. A misspelled directory URL would otherwise sit unnoticed until the day the setting mattered.
  • A section for a backend the chain does not name. auth.ldap.* without ldap in auth_backends is read by nobody — the chain is its only consumer — so it boots clean and the login page never consults the directory you configured. There is no reading of that file under which it does something, so it is an error.

A misspelled section under auth. is still an unknown key, exactly as before: the exemption is per registered name, never for the namespace.

Your own user table is a backend too​

Implement auth.UserProvider — the interface that describes how to reach your users — and register it:

nucleus.New().
FromConfigFile("nucleus.yml").
WithUserProvider(myUserStore)

It appears in the chain as local (or under a name you choose with WithUserProviderNamed). Modules reach the assembled chain through rt.AuthChain(), so a module that owns a sign-in page authenticates through the order the operator declared rather than going straight to the user table — the point of declaring an order is that it applies everywhere. The full worked slice — table, provider, login route — is Your first login.

A backend named in auth_backends that nobody registered fails at boot, naming what is registered — and, when it is one Nucleus publishes, naming the go get and the import that would register it. A typo in an authentication list should not wait until the first person tries to log in.

nucleus doctor --check auth reviews the chain from the configuration alone: a backend declared with no settings of its own, and a chain whose every entry depends on an outside system — which is the deployment where nobody can log in on the morning the directory is down, including whoever would fix it.

Federated sign-in (OIDC, SAML)​

A directory is a username and a password. An identity provider is not: the person leaves for it, authenticates there, and the answer arrives at a different URL some time later. There are no credentials to hand to the chain, so federated providers are declared separately and implement a separate contract of their own.

public_base_url: https://app.example.com

auth_federated:
- name: corp
provider: oidc
display_name: Corp SSO
- name: partners
provider: oidc

auth:
corp:
issuer: https://login.corp.example/
client_id: nucleus
partners:
issuer: https://idp.partners.example/

name is yours, provider is the protocol. They are different fields because two identity providers of the same protocol is ordinary — a corporate tenant and a partner one, staging and production. name is what appears in the URL, what the settings hang off (auth.<name>.*, the same subtree a credential backend reads), and what the sign-in button says when display_name is empty.

public_base_url is required whenever auth_federated is non-empty, and it is the address the browser uses — behind a reverse proxy or in a container that is not the address the process binds. The callback URL is derived from it, and it is the one value you have to register with your identity provider:

https://app.example.com/auth/corp/callback

Nucleus logs each one at startup for exactly that reason. A callback that does not match is a sign-in that only fails in production.

Mount the sign-in routes with nucleus.FederatedSignIn(). Nucleus builds the providers and owns the flow — it issues the anti-forgery state, holds the pending sign-in and refuses a callback that does not carry the state back. The module adds the routes for every instance auth_federated declares, and is what nucleus add oidc and nucleus add saml write into main.go:

nucleus.New().
FromConfigFile("nucleus.yml").
Mount(nucleus.FederatedSignIn()).
Start()
routewhat it does
GET /auth/<name>/startbegins the flow and redirects the browser to the identity provider; the anti-forgery token rides in an HttpOnly, SameSite=Lax cookie scoped to /auth/<name>/ — SameSite=None; Secure for a provider whose identity provider posts back from its own site (SAML) when public_base_url is https
GET and POST /auth/<name>/callbackcompletes the flow (a callback without that cookie is refused with 400, a state already used with 401), rotates the session token and records the identity in the session; with csrf_enabled the callback is exempt from the CSRF check, the cookie being what ties it to the browser that started the sign-in
GET /auth/<name>/metadatafor a provider that publishes service metadata (SAML): the document the identity provider is configured from

After a successful callback the session carries the identity under nucleus.SessionKeyFederatedInstance, SessionKeyFederatedUserID, SessionKeyFederatedUsername and SessionKeyFederatedEmail, and the callback answers with the identity as JSON — or redirects the browser to a path of the application:

modules:
federated:
redirect: / # a path of this application; an absolute URL is refused at boot

On the default stack the module grants the anonymous subject its routes and nothing else (the person signing in has no session yet). Mounted with no instance declared, it serves nothing and says so at startup.

What happens after the callback can be yours instead. Which session manager, which landing page, which account gets linked — an application that decides differently writes its own pair of handlers around the same flow:

set := a.AuthFederated // *auth.FederatedSet, built from auth_federated

// Mount on a.Router — the mux the server serves. http.Handle registers
// on http.DefaultServeMux, which the nucleus server never consults, so
// those routes would 404.
a.Router.HandleFunc(auth.FederatedStartPath("corp"),
func(w http.ResponseWriter, r *http.Request) {
redirectURL, state, err := set.Begin(r.Context(), "corp")
if err != nil {
http.Error(w, "sign-in unavailable", http.StatusBadGateway)
return
}
// Store `state` in a short-lived, HttpOnly cookie and hand it back
// to Complete on the callback.
http.SetCookie(w, &http.Cookie{
Name: "fed_state", Value: state, Path: "/",
HttpOnly: true, MaxAge: 300,
})
http.Redirect(w, r, redirectURL, http.StatusFound)
})

a.Router.HandleFunc(auth.FederatedCallbackPath("corp"),
func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("fed_state") // the cookie the start step set
if err != nil {
http.Error(w, "missing state", http.StatusBadRequest)
return
}
user, err := set.Complete(r.Context(), "corp", c.Value, r)
if err != nil {
http.Error(w, "sign-in failed", http.StatusUnauthorized)
return
}
// Establish your session and send the browser where you want it.
_ = user
})

Use auth.FederatedStartPath and auth.FederatedCallbackPath rather than writing the paths by hand: the callback URL logged at startup is derived from the same functions, so the URL you register with the identity provider is the one your route actually serves.

Hand-written sign-in routes must answer an unauthenticated browser. With the default-deny RBAC middleware mounted, every request without claims resolves to the anonymous subject, so the start route returns 403 before Begin ever runs unless you grant anonymous access to exactly these two paths (FederatedSignIn does this for its own routes):

for _, p := range []string{
auth.FederatedStartPath("corp"),
auth.FederatedCallbackPath("corp"),
} {
_ = a.Authorizer.AddPolicy("anonymous", p, "*")
}

An app built with app.WithOpenAuthz() skips this — there is no enforcement to open a hole in — but any app on the default stack needs it.

A runnable end-to-end version of all of the above — provider registration, route mounting, the anonymous policy, and a start→callback round-trip — is kept as an executable example in pkg/app/federated_routes_example_test.go, so the documented path is exercised in CI rather than only described here.

The framework keeps the anti-forgery state. A provider never sees it: Nucleus issues it, holds the pending sign-in, and refuses a callback that does not carry it back, before the provider is consulted at all. A state is single use and bound to the instance that issued it, and a sign-in left unfinished expires. That division is deliberate — the state parameter is the part of a redirect flow that works perfectly well when it is missing, right up until somebody attacks it, so a provider author is not given the chance to leave it out.

A subtree for an instance you did not declare in auth_federated is an unknown key, not a silently ignored one. The exemption is per declared instance and never for the whole auth. namespace.

Federated sign-in and auth_backends are independent, and most applications want both: the identity provider for everybody, and a local account or directory that still works the morning the identity provider does not.

One rule for anyone writing a backend: reject an unknown user and a wrong password identically, and in the same time. A backend that answers faster for a user that does not exist has published a list of your users, and because the chain stops on rejection, it publishes it for every backend behind it too.

OIDC​

An OpenID Connect provider ships in tree. nucleus add oidc wires it: the blank import that registers the provider, Mount(nucleus.FederatedSignIn()) in the nucleus.New() chain, and an instance named corp in nucleus.yml (written only when none of public_base_url, auth_federated and auth is set there yet; printed to merge by hand otherwise). Replace the placeholder issuer and client_id with your identity provider's values, and register <public_base_url>/auth/corp/callback with it.

By hand, it is a blank import for its side effect, the way a database driver is enabled, the module above, and an instance:

import _ "github.com/jcsvwinston/nucleus/pkg/auth/federated/oidc"
public_base_url: https://app.example.com
auth_federated:
- name: corp
provider: oidc
auth:
corp:
issuer: https://accounts.example.com
client_id: your-client-id
client_secret: your-client-secret
scopes: [openid, email, profile]
role_claim: groups # optional: where group membership lives
username_claim: preferred_username

FederatedSignIn serves the instance at /auth/corp/start and /auth/corp/callback.

What it verifies​

The flow is authorization code with PKCE, always — the code verifier is generated whether or not the provider advertises support, because a stolen authorization code is useless without it and there is no deployment where leaving it out is the right trade.

The id_token is checked against the provider's published keys and against every claim that decides whether this token is for this application, from this issuer, right now: audience, issuer, expiry, and the nonce the flow started with. Only asymmetric algorithms are accepted — an id_token signed HS256 with the client secret is how "alg confusion" starts.

Keys are fetched from jwks_uri and refetched when a token arrives with an unknown kid, which is what a provider's key rotation looks like from here; the refetch is rate-limited so an unknown kid cannot be used to point this application at the identity provider.

Group or role claims land in the identity's role list — an array claim and a space-separated string are both understood.

Why it is not a separate module​

The sibling modules exist so an application does not compile a backend's third-party dependencies. This provider has none: discovery is net/http and encoding/json, PKCE is crypto/sha256, and the token is verified with the JWT library the framework already links. A package nobody imports costs nothing in anybody's binary.

SAML​

A SAML 2.0 service provider ships as its own module, providers/auth-saml, because it needs an XML-signature library the framework does not link. nucleus add saml installs and wires it on the same seam and the same routes; it serves this application's SAML metadata at /auth/<name>/metadata as well. Its settings, what it verifies and what it does not cover are on SAML sign-in.