internal

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Overview

Package internal is every implementation of the auth module. Nothing outside modules/auth can import it, which is the compiler enforcing idea 3.

Index

Constants

View Source
const Path = "/api/v1/auth"

Path is where this module's routes live.

View Source
const ResetPath = "/auth/reset"

ResetPath is where the link points. It is a path within the application, so a notice can never send a tenant's people somewhere else, and the host the mail turns it into is that tenant's own.

Variables

This section is empty.

Functions

func ClientOf

func ClientOf(r *http.Request) contracts.Client

ClientOf is what a session records about where it was opened.

func RegisterOIDCRoutes

func RegisterOIDCRoutes(api *httpx.API, svc contracts.Service, users contracts.Users, p *Provider)

RegisterOIDCRoutes mounts the two legs of the authorization code flow. They are registered only when a provider is configured: a route that would answer "this application has no identity provider" is a route with nothing to say, and the boot gate counts what is mounted rather than what might have been.

func RegisterRoutes

func RegisterRoutes(api *httpx.API, svc contracts.Service, cookies Cookies)

RegisterRoutes mounts signing in and out, the caller's own identity, the three password routes and the two roles routes.

All but the last two are about the caller themselves, which is why they declare no permission: the public ones are for somebody who cannot sign in, and the signed-in ones are about a person rather than a resource. The roles routes are the exception and say so with role:manage — a role is what everybody else in the tenant may do.

func Sweep

func Sweep(svc *Service, tenants jobs.TenantLister) jobs.Job

Sweep is this module's periodic work: the rate limit counters whose window closed go, expired sessions and spent tokens go, and a role naming a permission no module defines is said out loud.

It is a job and not a subscription because nothing happens when a session expires — the clock passes, which is the distinction docs/adr/0004 draws.

The warning half is here rather than at boot, and that is a deliberate second choice. A role can only come to name an undeclared permission one way: a module left the composition, taking its permissions with it, because SetRole refuses to write one. So the moment worth reporting is a deploy, and this reports it within the hour of one. Doing it at boot would mean either a kernel that reads a module's table or a "run this at startup" field on the manifest that every module would find a use for, and neither is worth an hour.

func Undeclared

func Undeclared(roles []*contracts.Role, declared []tenancy.Grant) map[string][]string

Undeclared reports, for one tenant, every role row naming a permission the application does not define.

The hourly sweep is its one caller, once per tenant, inside that tenant's own transaction; it logs what it finds. It is a warning and not a refusal: the rows belong to customers and were legal when they were written — a module removed from a composition takes its permissions with it — so a sweep that refused would turn dropping a module into an installation somebody has to repair by hand. What it buys is that "this role grants nothing and nobody can see why" is a line in the log within the hour of the deploy that caused it rather than a support conversation months later.

Types

type Cookies

type Cookies struct {
	// contains filtered or unexported fields
}

Cookies mints and clears this module's two cookies.

Secure is on unless the application is reached at a local name, because a browser refuses a Secure cookie over http://localhost and a development machine that cannot sign in is a development machine nobody uses. The name follows from it: httpx.CookieName adds the __Host- prefix when the cookie will be Secure, which is what stops a page served at one customer's host setting a session cookie the browser then attaches at another's. Every tenant is reached at its own host, often as siblings under one domain, so that is not a hypothetical here.

SameSite is Lax rather than Strict so that following a link into the application from somewhere else does not land on a signed-out page; the cross-site writes Lax still allows are what kit/httpx's CSRF middleware refuses. Path is "/" on both, because __Host- requires it — the state cookie used to be scoped to this module's own prefix, and the prefix is worth less than a cookie a sibling host cannot forge.

func NewCookies

func NewCookies(secure bool) Cookies

NewCookies returns the cookie policy for an application reached at publicHost.

func (Cookies) Clear

func (c Cookies) Clear() http.Cookie

Clear is the cookie that removes the session: same name, same path, no value, expired. The attributes have to match or the browser keeps the one it has.

func (Cookies) Forget

func (c Cookies) Forget(base string) http.Cookie

Forget expires a cookie this policy set.

func (Cookies) Name

func (c Cookies) Name(base string) string

Name is what a cookie this policy sets is called, so a handler reading one back spells it the way it was written.

func (Cookies) Session

func (c Cookies) Session(id uuid.UUID, expires time.Time) http.Cookie

Session is the cookie that carries a session id. The value is the credential and nothing stores it; what the row holds is its hash.

func (Cookies) State

func (c Cookies) State(value string, seconds int) http.Cookie

State is the OIDC state cookie, and Forget removes it.

type Delivery

type Delivery struct {
	Mailer contracts.Mailer
	Hosts  contracts.Hosts
	Secure bool
}

Delivery is how a link with a secret in it leaves this module: the mailer that carries it, the lookup that turns a path into the recipient's own host, and whether that host is reached over https.

The three are one decision and travel together. A composition that wires no mailer issues no token either — a link nobody is sent is a live credential in a table for an hour, for nothing — and every route still answers as though it had.

type OIDC

type OIDC struct {
	Issuer       string
	ClientID     string
	ClientSecret string
	RedirectPath string
}

OIDC is one OpenID Connect provider, as this module needs it.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider is the lazily connected identity provider.

Lazily, because discovery is a network call: doing it in Module would make an unreachable provider a process that will not start, and an identity provider having a bad morning must not stop an application serving the people who are already signed in.

func NewProvider

func NewProvider(cfg OIDC, cookies Cookies, secure bool) *Provider

NewProvider prepares the provider. Nothing is dialled here.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is signing in and what a role may do.

func NewService

func NewService(users contracts.Users, notify contracts.Notifier, mail Delivery, operator []string) *Service

NewService returns the auth service. module.go constructs it.

func (*Service) Allowed

func (s *Service) Allowed(ctx context.Context, _ tenancy.Tenant, grant tenancy.Grant) (bool, error)

Allowed is kit/httpx's authorizer: one query per request that asks a permission question, in the request's own transaction, with no cache.

No cache is the decision. A permission cache is a window in which a revoked grant still works, and what it would save is a primary-key read of a handful of rows — so the cost of being exactly right is one round trip on the requests that need one, and requests by an anonymous caller or by a caller with no roles do not even make that.

The tenant is unused, and deliberately: the kernel has already refused an operator grant on a tenant that is not the operator's, and every row this reads is inside that tenant's own transaction. A second comparison here would be a check that cannot fail.

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, tx db.Tx[db.Tenant], r *http.Request) (tenancy.Principal, bool, error)

Authenticate is kit/httpx's identity hook: the one query that turns a cookie into a caller.

It runs after the host resolved and inside that tenant's transaction, so the session it looks for is a row of that tenant and nothing else. A session issued on one customer's host and presented on another's is simply not returned — row-level security, not a comparison — and the caller is anonymous.

A cookie that is not a uuid, or names a session that has expired or belongs to a user who is no longer active, is anonymous rather than an error: none of those is an outage, and answering 500 to somebody with a stale cookie would make signing out of a deleted account look like a broken deployment. A database that cannot be read is an error, and kit/httpx answers 500.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, tx db.Tx[db.Tenant], userID, keep uuid.UUID, current, next string) error

ChangePassword is the signed-in half of "I want a different password".

It asks for the current one, and that is the whole point of the route: a session cookie is something a browser attaches, so without this check a stolen cookie is a stolen account rather than a stolen session. It ends the other sessions in the same transaction, because a new password that leaves the old one's sessions working has not replaced anything.

func (*Service) Declare

func (s *Service) Declare(grants []tenancy.Grant)

Declare records the permissions the composition defines. module.go calls it once, inside Routes, with what the kernel read off every manifest.

func (*Service) Forget

func (s *Service) Forget(ctx context.Context, tx db.Tx[db.Tenant], email string) error

Forget publishes auth.reset_requested, and that is the whole of the request.

No lookup, no token, no mail: those are Reissue's, in the worker. The route this sits behind is public and has to cost the same whether or not anybody has the address, and doing the lookup here did not — a known address answered in 2.1 ms and an unknown one in 0.9 ms, two distributions that did not overlap, which is an account enumeration oracle with a stopwatch. One INSERT into the outbox is the same INSERT either way.

The cost of that honesty is unchanged and worth restating: a person who mistypes their own address is told nothing, and the mail that does not arrive is the message.

func (*Service) Identify

func (s *Service) Identify(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID, from contracts.Client) (*contracts.Identity, error)

Identify is the lookup every request with a session cookie makes: one row, by primary key, in this tenant's transaction, joined to the user so that the caller's roles arrive with them and the authorizer needs no second query.

func (*Service) Login

func (s *Service) Login(ctx context.Context, tx db.Tx[db.Tenant], email, password string, from contracts.Client) (*contracts.Session, *contracts.Identity, error)

Login verifies a password and opens a session.

The three refusals — locked out, no such address, wrong password — cost the same and, apart from the lockout, say the same. An address nobody has still pays for one argon2id hash (usercontracts.EqualWork), because the difference between "no such account" and "wrong password" is otherwise a stopwatch.

func (*Service) Logout

func (s *Service) Logout(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) error

Logout ends a session. Ending one that is already gone is not an error: the caller wanted to be signed out and they are.

func (*Service) MayAsk

func (s *Service) MayAsk(ctx context.Context, ip string) bool

MayAsk counts one forgotten-password request from an address. See contracts.Service.

func (*Service) MayRedeem

func (s *Service) MayRedeem(ctx context.Context, ip string) bool

MayRedeem counts one reset-token redemption from an address. See contracts.Service.

func (*Service) Offer

func (s *Service) Offer(ctx context.Context, tx db.Tx[db.Tenant], userID uuid.UUID) error

Offer issues a set-password token for somebody who has just been invited.

It is the whole body of the user.invited subscription, and it is the same token Forget issues: an invitation and a reset are one fact — somebody who cannot sign in has been sent a link that lets them choose a password once — and two mechanisms would be two expiries to keep in step.

A user who already has a password is skipped. user.invited is published by the bootstrap's Provision as well as by Invite, and the first administrator of an installation has a password already, printed on the terminal.

func (*Service) Open

Open creates a session for a user somebody else has already recognised. The OIDC callback is its caller.

func (*Service) Permissions

func (s *Service) Permissions(_ context.Context, tx db.Tx[db.Tenant], roles []string) ([]string, error)

Permissions is the union of what these roles grant in this tenant: one query, in the request's own transaction, under the tenant's own policy.

Nothing is cached. A permission cache is a window in which a revoked grant still works, and the query it would save is a primary-key lookup of at most a handful of rows on a table the size of a role list.

func (*Service) Precheck

func (s *Service) Precheck(ctx context.Context, email, ip string) contracts.Verdict

Precheck is the limiter's verdict, read from the shared counters. See contracts.Service.

func (*Service) Purge

func (s *Service) Purge(_ context.Context, tx db.Tx[db.Tenant]) (int64, error)

Purge deletes this tenant's expired sessions and spent tokens, a batch per call, until fewer than a batch remain.

It runs in the caller's transaction and returns a count, so the hourly job can open one transaction per batch: a tenant with a million dead sessions is a thousand short transactions rather than one long lock. Both limits are applied, because a session that never passed its sliding expiry has still passed the absolute one — that is what the cap is for — and the cutoffs are computed by the database, so two workers whose clocks have drifted delete the same rows.

func (*Service) Reissue

func (s *Service) Reissue(ctx context.Context, tx db.Tx[db.Tenant], email string) error

Reissue is the worker's half of the forgotten-password flow: the lookup the request refused to do, done where no stopwatch can reach it.

Every path returns nil. An address nobody has, a deactivated account, a composition with no mailer, a person who was sent a link a moment ago: none of those is a failure the outbox should retry four times and dead-letter, and none of them is anything a stranger gets to measure.

func (*Service) Reset

func (s *Service) Reset(ctx context.Context, tx db.Tx[db.Tenant], token, password string) error

Reset consumes a token, sets the password and ends every session.

Every one, including any the caller holds: whoever is resetting a password has already shown they were not relying on a session, and whoever else held one may be the reason it is being reset. The row is deleted rather than flagged, so "used once" is the row being gone — two requests racing on one token is one DELETE returning a row and one returning none, decided by Postgres rather than by a read and a write this code would have to get right.

func (*Service) RevokeSessions

func (s *Service) RevokeSessions(_ context.Context, tx db.Tx[db.Tenant], userID, except uuid.UUID) error

RevokeSessions ends every session this user has but one.

It is the second half of every password change: the point of setting a new password is that the old one stops working, and a session opened with the old one is the old one still working. except keeps the session the person is asking from, so changing a password does not sign you out of the page you changed it on; the nil UUID keeps none.

func (*Service) Roles

func (s *Service) Roles(_ context.Context, tx db.Tx[db.Tenant]) ([]*contracts.Role, error)

Roles is every role in this tenant, in name order, under the tenant's own policy — so this is the same query from every host and answers about one customer whichever administrator asks.

func (*Service) SeedRoles

func (s *Service) SeedRoles(_ context.Context, tx db.Tx[db.System], tenantID uuid.UUID, operator bool) error

SeedRoles installs the two roles a tenant starts with, in the transaction that created it. ON CONFLICT DO NOTHING, because a tenant that already has an admin role has one that somebody may have edited, and seeding is not the place to put it back.

The operator's own tenant gets one permission more, named rather than implied: the wildcard does not satisfy an operator grant, so tenant:manage has to appear in the list for anybody to reach the control plane. That row is the whole of the installation's own authority, and it exists in exactly one tenant — the one the bootstrap created.

func (*Service) SetRole

func (s *Service) SetRole(ctx context.Context, tx db.Tx[db.Tenant], name string, permissions []string, declared []tenancy.Grant) (*contracts.Role, error)

SetRole writes what a role grants, creating it if it is new.

Every permission is checked against the list the application declares, which the caller is handed by the kernel. A role naming a permission nothing defines is a grant that can never be exercised and reads, to whoever wrote it, exactly like one that can — the failure is silent and permanent, and it is the one an authorization screen makes easy to cause.

An operator permission outside the operator's own tenant is refused for a sharper reason: the kernel would refuse every request under it anyway, so writing one is either a misunderstanding of what the permission is or an attempt to grant the installation to a customer. Both are 422s.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL