Help us shape the future of Go! Take the 2026 Go Developer Survey

auth

package
v0.52.0 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 30 Imported by: 0

Documentation

Overview

Package auth — audit_middleware.go

Chi middleware that watches mutating routes and records a row in audit_log when the response is a 2xx. Wraps the response writer to capture the final status code and reads the *model.User from the request context (so it must be installed AFTER auth.Middleware).

Package auth defines the AuthDriver interface, registry, and middleware.

Drivers live under auth/drivers/ and self-register via init() blocks. The middleware reads request headers/cookies, attempts each enabled driver in turn, and stores the resulting *model.User on the request context for downstream handlers.

Index

Constants

View Source
const (
	NotAdoptedOtherTenant = "other_tenant"
	NotAdoptedNoTenant    = "no_tenant"
	AdoptedSSOAccount     = "sso_account"
)

Why an older account is not adopted, or adopted without being renamed. Stable strings: they are stored in audit rows.

View Source
const (
	ViaAPI = "api"
	ViaMCP = "mcp"
)

The doors an audited write through a token came in by, as metadata `via`. A session's write (the panel) carries none.

View Source
const (
	ReasonAutoCreateOff    = "auto_create_off"
	ReasonGroupNotAllowed  = "group_not_allowed"
	ReasonForbiddenAccount = identity.RefuseForbidden
	ReasonInvalidName      = identity.RefuseInvalidName
)

Reasons a first sign-in is refused. Stable strings: they are stored in audit rows.

View Source
const (
	AuditHandoffIssued  = "auth.handoff_issued"
	AuditHandoffUsed    = "auth.handoff_used"
	AuditHandoffRefused = "auth.handoff_refused"
)

Audit actions of a handoff. One spelling each, so the audit page's `auth.` filter finds all three.

View Source
const (
	HandoffRefuseUnknown     = "unknown" // no such ticket, or already spent
	HandoffRefuseExpired     = "expired" // outlived its life
	HandoffRefusePurpose     = "wrong_purpose"
	HandoffRefuseHost        = "wrong_host"   // shown on another address
	HandoffRefuseActor       = "actor_gone"   // the actor may no longer act
	HandoffRefuseSubject     = "subject_gone" // the subject may no longer sign in
	HandoffRefuseTenant      = "wrong_tenant" // the subject is not the host's tenant's
	HandoffRefuseActorPolicy = "actor_is_not_subject"
)

Why a ticket is refused. Stable strings: they are stored in audit rows.

View Source
const (
	// ProbeOK: this step was checked and holds.
	ProbeOK = "ok"
	// ProbeFail: this step was checked and does not hold.
	ProbeFail = "fail"
	// ProbeUnchecked: this step cannot be checked without a person signing
	// in (an OIDC redirect address, a public client). Said, never assumed.
	ProbeUnchecked = "unchecked"
)

Probe statuses.

View Source
const (
	// SSOReasonAutoCreateOff: no account yet, and the provider opens none at a
	// first sign-in (auto_create off). Same string as ReasonAutoCreateOff.
	SSOReasonAutoCreateOff = ReasonAutoCreateOff
	// SSOReasonGroupNotAllowed: no account yet, and none of the person's
	// groups is in the provider's allowed_groups.
	SSOReasonGroupNotAllowed = ReasonGroupNotAllowed
	// SSOReasonNoEmail: the identity provider's answer carried no e-mail
	// address, which is what an account is found by.
	SSOReasonNoEmail = "no_email"
	// SSOReasonIdPDenied: the identity provider sent the browser back with an
	// error instead of a sign-in (the person cancelled, or the IdP refused).
	SSOReasonIdPDenied = "idp_denied"
	// SSOReasonIdPError: the sign-in could not be completed with the identity
	// provider (the code exchange or the id_token check failed).
	SSOReasonIdPError = "idp_error"
	// SSOReasonExpired: the browser came back without the sign-in this server
	// started (no state cookie, or another one): too late, another tab, or
	// cookies blocked.
	SSOReasonExpired = "expired"
	// SSOReasonAccountDisabled: the account exists and is disabled.
	SSOReasonAccountDisabled = "account_disabled"
	// SSOReasonTenantSuspended: the account's tenant is suspended.
	SSOReasonTenantSuspended = "tenant_suspended"
	// SSOReasonMaintenance: multi-tenant mode is off on an install that has
	// tenants, so only the platform's own accounts may sign in.
	SSOReasonMaintenance = "maintenance"
	// SSOReasonBusy: too many SSO sign-ins are in flight on this server to
	// start another one (authsetup's bounded flow book); try again shortly.
	SSOReasonBusy = "busy"
	// SSOReasonForbiddenAccount: the operating system said, only after the
	// password, that the account is a system or built-in administrator account
	// (Windows reads it from the token's SID). Same string as
	// ReasonForbiddenAccount; written out so the web gate can read it.
	SSOReasonForbiddenAccount = "forbidden_account"
	// SSOReasonEmailUnverified: the account with this address (in the tenant
	// the sign-in is for) is bound to no SSO identity yet, and the identity
	// provider did not say the address is verified, so it is not signed in to
	// by the address alone (docs/SSO.md, "Which account an SSO sign-in
	// opens"). An administrator can trust the provider's addresses.
	SSOReasonEmailUnverified = "email_unverified"
	// SSOReasonAccountPending: the sign-in opened a new account switched off,
	// because the identity provider did not say the address is verified; an
	// administrator switches it on (model.DisabledPendingApproval). Also the
	// answer for that account's later sign-ins until then.
	SSOReasonAccountPending = "account_pending"
	// SSOReasonIdentityMismatch: the account with this address (in the
	// tenant the sign-in is for) is bound to ANOTHER SSO identity (issuer,
	// sub): the same address, someone else, or the same person after the
	// identity provider gave them a new identity. An administrator can remove
	// the account's SSO bind (docs/SSO.md).
	SSOReasonIdentityMismatch = "identity_mismatch"
)

── What the sign-in page may say about a refused SSO sign-in ───────────────

An SSO sign-in that does not end in a session sends the browser back to the sign-in page (`/admin/login?error=oidc`). Until 0.50 that was ALL it said: "SSO sign-in failed", whether the identity provider had refused the person, the install opens no account at a first sign-in, or the person is in no group that may sign in. The reason was in the server log only, so the person could not tell "try again" from "ask your administrator".

Now the redirect carries a REASON CODE (`&reason=auto_create_off`), one of the constants below, and the page says what happened and what to do in the reader's language (web/src/lib/ssoRefusal.ts, `login.ssoRefused.*`). Never a sentence and never the identity provider's own error text: the code is all that crosses, so nothing the IdP or a crafted callback URL says reaches the screen.

⚠⚠ ONE answer stays generic, on purpose (owner, 2026-10-02): an e-mail address that already has an account in ANOTHER tenant. Saying so would tell whoever holds an identity at a shared IdP which other tenants exist and that this address is a member of one. That refusal, and every failure that is not classified here (a realm nobody has, an instance not bound to the realm, a database error), carries NO code: the redirect is byte for byte the one a plain failure gets. A code reaches the page only when it is wrapped in an *SSORefusalError or is a first-login refusal SSOReason lets through, so an unclassified error can never leak one by accident.

The server log keeps the full reason in every case (handlers.OIDCCallback).

The same codes reach the page two other ways:

  • the header proxy's first-login refusals, in the 401 body of every request the proxy names the person in (RefusalToTell, Middleware): the proxy has said who they are, there is no password to confirm;
  • a password provider's (LDAP, PAM, Windows) refusals AFTER a right password, only when its operator switched show_refusal_reason on (RefusedAfterPassword, handlers.Auth.Login answers 403 with the code). Off - the default - they are the answer a wrong password gets.
View Source
const (
	VerbRead   = "read"
	VerbWrite  = "write"
	VerbDelete = "delete"
)

The file verbs an API token can carry. They mirror drivers/apitoken's ScopeRead / ScopeWrite / ScopeDelete, declared here too because the driver imports this package and not the reverse.

View Source
const AuditAccountAdopted = "auth.account_adopted"

AuditAccountAdopted is the audit action of an older account taken over: re-keyed to the address the e-mail rule gives it (`renamed: true`), or signed in to as it is (`renamed: false`, `reason: sso_account`).

View Source
const AuditAccountNotAdopted = "auth.account_not_adopted"

AuditAccountNotAdopted is the audit action of an older account that was left alone. The row names no user, so only the platform operator reads it.

View Source
const AuditFirstLoginRefused = "auth.first_login_refused"

AuditFirstLoginRefused is the audit action of a refused first sign-in.

View Source
const AuditLegacyAccountElsewhere = "auth.legacy_account_elsewhere"

AuditLegacyAccountElsewhere is written once per older account found in another tenant than the sign-in's — the mark that the platform operator has been told (SetLegacyAccountAlarm). No user on the row: a tenant administrator never reads another tenant's name from it.

View Source
const MaxSyncProblems = 20

MaxSyncProblems bounds the problems one report keeps.

View Source
const ShowRefusalReasonKey = "show_refusal_reason"

ShowRefusalReasonKey is the setting of a password provider (LDAP, PAM, Windows; authsetup.Schema) that lets its sign-in form say why a person whose password was right gets no session. OFF by default: see RefusedAfterPassword.

Variables

View Source
var (
	// ErrAccountDisabled: the account exists and an administrator turned it off.
	// A test must not switch a disabled account back on.
	ErrAccountDisabled = errors.New("auth: the account is disabled")
	// ErrNotSupertenant: the account belongs to a tenant, so an admin role on it
	// would be a tenant administrator, not the platform's.
	ErrNotSupertenant = errors.New("auth: the account belongs to a tenant, not to the platform")
)

Errors of GrantSuperAdmin.

View Source
var ErrBusy = errors.New("auth: the sign-in provider is busy, try again")

ErrBusy is returned when a sign-in cannot even be tried because the provider is at its limit of simultaneous attempts. It is a temporary condition, NOT a judgement of the credentials: callers answer "try again", never "wrong password", and do not count it as a failed attempt.

View Source
var ErrFirstLoginRefused = errors.New("auth: first sign-in refused")

ErrFirstLoginRefused is matched (errors.Is) by a refusal. Providers turn it into their one ambiguous answer; only the operator sees the reason.

View Source
var ErrHandoffRefused = errors.New("auth: handoff refused")

ErrHandoffRefused is matched (errors.Is) by every refusal; the reason is in the *HandoffRefusal.

View Source
var ErrNoTenantForLogin = errors.New("auth: cannot determine which tenant this login belongs to")

ErrNoTenantForLogin is returned when a multi-tenant install cannot tell which tenant a just-in-time account belongs to.

⚠ It is deliberately a REFUSAL and not a fallback. The only fallback available is `default`, and homing a directory user there is the bug: it hands a confine-exempt, every-storage account to whoever the directory says exists. Refusing costs an operator one config line (`provider:`) or one DNS host; the fallback costs them the tenant boundary.

View Source
var ErrOtherTenant = errors.New("auth: the account belongs to another tenant")

ErrOtherTenant: the account belongs to another tenant than the one an operating-system provider was switched on in, so it cannot become that tenant's administrator.

View Source
var ErrRealmConflict = errors.New("auth: the realm names another tenant than the address")

ErrRealmConflict: the realm typed names another tenant than the address the client reached.

View Source
var ErrUnauthorized = errors.New("auth: unauthorized")

ErrUnauthorized is returned when no driver could authenticate a request.

View Source
var ErrUndecided = errors.New("auth: the sign-in provider could not decide")

ErrUndecided is matched (errors.Is) by an error that means a provider could not judge the credentials at all — a missing tool, a broken setup, a timeout. It is not ErrUnauthorized: nothing was decided.

View Source
var ErrUnknownRealm = errors.New("auth: no tenant has this realm")

ErrUnknownRealm: the realm typed is no tenant's.

Functions

func AIAdminAction

func AIAdminAction(method, path, id, name string) (string, string, string)

AIAdminAction derives the audit (action, targetType, targetID) for a request on the AI admin surface (/api/ai/admin/*). It normalizes the path down to the native /api/admin/* form, reuses ActionForPath's mapping, and prefixes the resulting action with "ai." so AI-token-driven admin writes are clearly distinguishable from native-panel ones in the Audit page. id/name are the corresponding chi URL params ("" when absent). Returns an empty action when the call isn't an auditable mutating admin op.

Used both by the HTTP AuditMiddleware (for /api/ai/admin REST calls) and by the MCP admin tools (which bypass HTTP middleware and audit in-process).

func APITokenMiddleware

func APITokenMiddleware(store db.Store) func(http.Handler) http.Handler

APITokenMiddleware authenticates a request strictly via an API token (X-Filex-Token or Authorization: Bearer), attaches both the bound user and the matched token to the context, and rejects anything else with 401.

Unlike the generic Middleware chain it does NOT accept cookie sessions — the AI/MCP namespace is token-only by design. Use RequireScope after it to gate individual verbs.

func ActionForPath

func ActionForPath(method, p, id, name string) (string, string, string)

ActionForPath maps a (method, path) pair to (action, target_type, target_id).

The path matching uses strings.HasPrefix on /api/admin/storages/ etc. so trailing slashes / IDs are handled uniformly. We intentionally don't try to read JSON bodies — only URL-derivable identifiers. id/name are the already-resolved chi URL params (passed in so this function is reusable outside an *http.Request context, e.g. from the in-process MCP invoker).

func AddAuditDetail

func AddAuditDetail(ctx context.Context, key string, v interface{})

AddAuditDetail records one fact for the request's audit row; a no-op when the request is not being audited.

func AdoptAccount added in v0.50.0

func AdoptAccount(ctx context.Context, store AdoptStore, a Adoption) (*model.User, error)

AdoptAccount returns the older account the person signs in to — re-keyed to a.Email, or (bound to SSO) as it is — or nil (and no error) when there is none to adopt. The caller has already looked for an account at a.Email and found none; on nil it goes on to the first-login rule.

The unique index on e-mail is the arbiter of a race: if re-keying fails and an account holds the new address by then (another sign-in of the same person, an administrator), that account is returned — never a second one opened.

func AllowVerb added in v0.49.0

func AllowVerb(w http.ResponseWriter, r *http.Request, verb string) bool

AllowVerb is the in-handler form, for a route whose verb depends on the request (POST /api/files/manager?action=…, POST /api/files/ops {kind}). It answers the refusal itself and reports whether the handler may go on.

func AnnotateToken

func AnnotateToken(store db.Store) func(http.Handler) http.Handler

AnnotateToken attaches a valid API token to the context and NOTHING else. It never authenticates a user, never consults the session drivers, and — the whole point — never rejects: an unknown, revoked, expired or malformed token, a disabled owner, a bogus X-Filex-Token-User all simply leave the context untouched and the request proceeds as anonymous.

It exists for PUBLIC routes that must merely describe the caller — /api/files/capabilities reports `caller_kind` so the explorer knows whether to draw a person's surfaces. Reaching for MiddlewareWithToken there was wrong in a way that only shows up later: that chain answers 403 to a disabled account, so a disabled user's browser (which still holds a cookie) would have had the LOGIN page's capabilities probe fail closed. A public probe that can fail is a worse trade than a capability field.

⚠ Do not add a rejection path here. If a route needs one it needs a different middleware; this one's contract is that a public page can always get an answer.

func AnnotateUser

func AnnotateUser() func(http.Handler) http.Handler

AnnotateUser attaches the caller's user to the context when a credential resolves to one, and NOTHING else. It never rejects.

Middleware(false) is nearly this, but it still answers 403 for a disabled account — which is why /api/capabilities cannot use it: that route is fetched by the login screen and the public share pages, and a disabled user's browser still holds a cookie. Here a credential that does not work is simply an anonymous caller.

The one thing the annotation buys is the ability to answer a PUBLIC endpoint differently for a stranger: /api/capabilities publishes the operator's external service hosts, and a caller with no credential has no business learning them (see handlers.Capabilities).

⚠ Cheap on the anonymous path: every driver returns ErrUnauthorized immediately when the request carries no credential of its kind — no database round trip, no LDAP bind.

func ApplyStartingRole added in v0.50.0

func ApplyStartingRole(ctx context.Context, store startingRoleStore, driver string, user *model.User, groups []string)

ApplyStartingRole gives a NEW account the custom role its groups name, and the built-in role that role implies (perm.StartingRole / perm.RoleHolder). It is the group → role mapping for every provider: the permission rules that target an SSO group are the only table. Failures are logged, not fatal: the account then simply starts with its default role.

Only at creation: afterwards the role is the person's own, changed on their page like anyone else's. An administrator (the header proxy's admin role, the OIDC admin group) is bound by no role — callers do not call this for one.

func AuditHandoff added in v0.50.0

func AuditHandoff(ctx context.Context, store any, action string, t *HandoffTicket, host, reason string)

AuditHandoff writes one row of a ticket's life: issued, used or refused (reason). Never the code. The row is filed under the SUBJECT (target) and, as its user, the ACTOR — the account that caused it — so an impersonation will read as "who did it to whom". A refusal with no ticket (an unknown or already spent code) is not audited: anybody can send one, and an anonymous endpoint must not be a way to fill the audit log; it is logged at debug.

func AuditMiddleware

func AuditMiddleware(store db.Store) func(http.Handler) http.Handler

AuditMiddleware returns a chi middleware that records audit_log entries for successful (2xx) mutating requests on a curated set of paths.

func Audited added in v0.50.0

func Audited(ctx context.Context) bool

Audited reports whether a door is recording this request's audit row - the middleware above, or an in-process AI door (an MCP tool) that writes the row its REST twin writes. A handler that also writes a row of its own asks it first: when a door records the request, the handler names THAT row (SetAuditAction, SetAuditTarget, AddAuditDetail) instead of writing a second one beside it.

func CallerMayAdminister

func CallerMayAdminister(ctx context.Context) bool

CallerMayAdminister reports whether the request's caller may use an account-wide operator power: the account is an administrator AND, when the request was authenticated by a token, that token may administer.

A session (no token on the context) is judged by the account alone, exactly as before.

func CfgBool

func CfgBool(cfg map[string]any, key string) bool

CfgBool reads a configuration value as a bool. The settings table stores every value as a string, so "true"/"1"/"yes"/"on" count as well as a JSON true.

func CfgBoolDefault added in v0.50.0

func CfgBoolDefault(cfg map[string]any, key string, def bool) bool

CfgBoolDefault is CfgBool with a default for a key that is absent (or an empty string): the settings table stores every value as a string, and "" is how an unset field arrives.

func CfgString

func CfgString(cfg map[string]any, key string) string

CfgString reads a configuration value as a trimmed string.

func CheckAdminCandidate added in v0.50.0

func CheckAdminCandidate(ctx context.Context, store superAdminStore, email string, tenant *model.Provider) error

CheckAdminCandidate is CheckSuperAdminCandidate for the scope a test account signed in in (docs/TENANT-ADMIN.md): the platform's own tenant (nil, or the supertenant) asks CheckSuperAdminCandidate; a tenant accepts an account that does not exist yet or is its own and enabled (ErrAccountDisabled, ErrOtherTenant otherwise).

func CheckHandoffTarget added in v0.50.0

func CheckHandoffTarget(ctx context.Context, store db.Store, multiTenant bool, t *HandoffTicket, host string) (*model.User, error)

CheckHandoffTarget is the re-check every purpose makes at the target, after Redeem: the actor still exists and is enabled; the subject too, and may still sign in (auth.LoginAllowed — a suspended tenant, maintenance mode); and the subject is the tenant's the host names, which is the tenant the ticket was issued for. It answers the subject, or a refusal. Purpose-specific rules come on top: for HandoffLogin the actor must be the subject.

func CheckSuperAdminCandidate added in v0.50.0

func CheckSuperAdminCandidate(ctx context.Context, store superAdminStore, email string) error

CheckSuperAdminCandidate says, without changing anything, whether GrantSuperAdmin would accept the account: nil when it does not exist yet or is a usable platform account, ErrAccountDisabled / ErrNotSupertenant when it exists and cannot be promoted.

func ClaimSource added in v0.52.0

func ClaimSource(ctx context.Context, store interface {
	SetUserAuthSource(ctx context.Context, userID int64, source string) error
}, u *model.User, source string)

ClaimSource labels an existing account with the directory it just signed in through (model.AuthSource*), when it has no password of its own here and is still labelled Local — an account made before migration 00084 by that directory. An account with a password here was made here: it stays Local whatever it later signs in with.

func DoorAction added in v0.50.0

func DoorAction(action string, aiDoor bool) string

DoorAction is the action an admin write is filed under: through the AI surface (aiDoor - /api/ai/admin or an admin_* MCP tool) it is prefixed "ai.", except for the door-agnostic families above. "" stays "".

func GrantSuperAdmin added in v0.50.0

func GrantSuperAdmin(ctx context.Context, store superAdminStore, email string) (changed bool, err error)

GrantSuperAdmin makes email a platform administrator: the account is created (in the supertenant, no password — it signs in through the provider) or, when it exists, given the admin role. It reports whether anything changed.

It is the ONE deliberate exception to "auto_create is off" and to the first-login rule: an operating-system provider that has just proved it works for a person must not leave that person locked out of the instance they are setting up. The caller has verified the account really signed in.

func GrantTenantAdmin added in v0.50.0

func GrantTenantAdmin(ctx context.Context, store tenantAdminStore, email string, tenant *model.Provider) (bool, error)

GrantTenantAdmin makes email an administrator of a tenant: the account is created in the tenant (no password - it signs in through the provider) or, when it is the tenant's already, given the admin role. It reports whether anything changed. The tenant counterpart of GrantSuperAdmin, for an operating-system provider switched on in a tenant for the first time (the owner's decision, 2026-10-01).

The three beats of every creation that homes an account (auth.provision): create, home, and on a failure to home delete the row rather than leave a half-made account in the platform's own tenant.

func GroupsIntersect added in v0.50.0

func GroupsIntersect(a, b []string) bool

GroupsIntersect reports whether two group lists share a member. Compared with tagname.Key: case, surrounding space and the Turkish dotted/dotless I fold, so "Yöneticiler" and "YÖNETİCİLER" are the one group.

func LoginAllowed

func LoginAllowed(ctx context.Context, store db.Store, multiTenant bool, u *model.User) bool

LoginAllowed reports whether an authenticated user may start a session.

Two tenant-level gates (docs/MULTI-TENANCY.md):

  • SUSPEND: a disabled provider's users cannot log in (data intact) — in both modes. The supertenant cannot be suspended out of its own platform.
  • MAINTENANCE MODE: multi-tenant OFF on an install that has grown tenants ⇒ only the supertenant provider's users may log in; tenants are locked out until the flag returns (reversible, nothing touched). A plain single-tenant install is unaffected — its only provider IS the supertenant.

One user-level gate:

  • DISABLED ACCOUNT: users.enabled = false (migration 00022) refuses the session outright, in both modes, with files/quota/grants untouched. Checked first so it holds in a plain single-tenant install too.

Otherwise it fails toward availability: a NULL provider (bootstrap/legacy admin) or an unresolvable provider is allowed, so an operator is never locked out by a glitch. Only a resolvable, non-supertenant tenant is ever refused.

func LoginBlockReason added in v0.50.0

func LoginBlockReason(ctx context.Context, store db.Store, multiTenant bool, u *model.User) string

LoginBlockReason is LoginAllowed with the gate that said no: "" (allowed), SSOReasonAccountDisabled (SSOReasonAccountPending for an account an SSO sign-in opened switched off, still waiting for an administrator), SSOReasonTenantSuspended or SSOReasonMaintenance. The SSO callback sends it to the sign-in page (handlers.OIDCCallback), which used to land the person on a page that said nothing at all.

func LoginHostFrom

func LoginHostFrom(ctx context.Context) string

LoginHostFrom returns the stamped host, or "".

func LoginRealmAdmits added in v0.50.0

func LoginRealmAdmits(ctx context.Context, u *model.User) bool

LoginRealmAdmits is Admits for the context's realm; true when there is none.

func Middleware

func Middleware(required bool) func(http.Handler) http.Handler

Middleware tries each enabled auth Driver in order. The first one that returns a non-nil user wins; subsequent drivers are not consulted.

On failure with required=true, a 401 JSON error is written and the downstream handler is NOT called. With required=false, the request proceeds with no user attached (useful for public viewer endpoints).

func MiddlewareWithToken

func MiddlewareWithToken(store db.Store, required bool) func(http.Handler) http.Handler

MiddlewareWithToken authenticates EITHER via an API token (X-Filex-Token / Bearer) OR, failing that, the regular user-session driver chain. It powers the /api/files surface so a host app can proxy with a root-confined API token (see package confine) while the native admin panel keeps using its cookie/JWT session. An API-token bearer and a session JWT never collide: the token's sha256 simply won't match a JWT, so we fall through cleanly.

func Names

func Names() []string

Names returns the registered driver names sorted alphabetically.

func NetReason

func NetReason(err error) string

NetReason classifies a connection error into the reasons the panel has words for: refused, timeout, dns, tls, or other.

func NoteHome added in v0.50.0

func NoteHome(ctx context.Context, home *model.Provider, viaPin bool)

NoteHome tells the sign-in's realm that a driver's pin homed it (see LoginRealm.NotePinned). A no-op for anything else.

func ProbeLangFrom added in v0.50.0

func ProbeLangFrom(ctx context.Context) string

ProbeLangFrom returns the language stamped by WithProbeLang, "" when none.

func ProbeOKAll

func ProbeOKAll(checks []ProbeCheck) bool

ProbeOKAll reports whether a probe passed: nothing failed and something was actually checked.

func ProvisionFirstLogin added in v0.50.0

func ProvisionFirstLogin(ctx context.Context, store ProvisionStore, f FirstLogin) (*model.User, error)

ProvisionFirstLogin applies the first-login rule to a person with no account: it refuses (a *FirstLoginRefusal, errors.Is ErrFirstLoginRefused) or creates the account through the ONE creation path — create, home in the tenant, and delete the row again if it cannot be homed.

The caller has already looked for an existing account; this is only for the "no such account" branch.

func ProvisionUser

func ProvisionUser(ctx context.Context, store ProvisionStore, h TenantHoming, driver, email, role string) (*model.User, error)

ProvisionUser creates `email` and homes it in the tenant this login arrived for. `driver` names the caller, for the log line an operator will need when a directory user cannot sign in.

The three beats are the ones the invite path established: create; home; and on a failure to home, DELETE the row rather than leave a half-created supertenant account behind.

func RecordSignInGroups added in v0.50.0

func RecordSignInGroups(ctx context.Context, store db.Store, driver string, user *model.User, groups []string, created bool) *model.User

RecordSignInGroups is what a sign-in does with the groups its identity provider reported. Every provider that reads groups calls it at the point it records them — OIDC (`role_claim`), LDAP (`group_attr`), the operating system (pam, windows) and the header proxy (`header_roles`) — so there is ONE rule, whichever way a person signs in:

  1. the groups are stored, replacing the last sign-in's (user_sso_groups): what a role's SSO-group target and a changed group link read;
  2. a NEW account that is not an administrator starts with the custom role they name (ApplyStartingRole) — once, at creation;
  3. the account joins every filex group of its tenant linked to one of them and leaves every group it was in only through such a link (group.SyncLinked): the directory is the authority, a member added by hand stays;
  4. its built-in level follows the role its groups now give it (group.SyncLevels) — also when no membership moved, since an admin mapping may just have demoted it.

Nothing here is fatal to the sign-in: a failure is logged, and the next sign-in (or a change to a group's links) puts it right. It returns the account as stored afterwards — its level may have moved.

func RefusalToTell added in v0.50.0

func RefusalToTell(refusal error) error

RefusalToTell is ErrUnauthorized carrying the reason of a refusal the person may be told (SSOReason): the first-login rule's two, or a forbidden account. Anything else is plain ErrUnauthorized. Every caller that asks errors.Is(err, ErrUnauthorized) - the login chain, the file protocols, the auth middleware - still reads it as a refusal.

func RefuseFirstLogin added in v0.50.0

func RefuseFirstLogin(ctx context.Context, store any, driver, identifier, reason string) error

RefuseFirstLogin records why a first sign-in was refused (log + audit row) and returns the refusal error. Providers that refuse for a reason of their own — an OS login whose name is a system account — call it directly so the reason reaches the operator the same way.

⚠ Nothing secret goes in: the provider, the reason, the identifier the person gave (an e-mail or a login name) and the host they came to. Never a password, a token or the group list.

func RefuseVerb added in v0.49.0

func RefuseVerb(w http.ResponseWriter, verb string)

RefuseVerb writes the refusal a token without verb gets — the same body RequireScope has always answered on /api/ai, so a client reads one message whichever door it knocked on.

func RefusedAfterPassword added in v0.50.0

func RefusedAfterPassword(tell bool, refusal error) error

RefusedAfterPassword is how a password provider (LDAP, PAM, Windows) answers a person whose password was RIGHT but who may not sign in here: the first-login rule refused them, or the operating system named a system account only once the password was in.

⚠⚠ By default it is plain ErrUnauthorized - the answer a wrong password gets. A different answer would come only after the directory accepted the password, so it would tell anybody guessing that a password of that directory is right, and the same directory usually signs people in to other services too. Only a provider whose operator switched show_refusal_reason on (tell) answers with the reason code (RefusalToTell); the sign-in form then says why.

A refusal that comes BEFORE the password is judged (a name that is a system account, an unknown account) and an account of another tenant are never passed here: they stay the one answer whatever the setting.

func Register

func Register(name string, f Factory)

Register adds a driver factory keyed by name. Called from drivers' init().

func RequireAdmin

func RequireAdmin(next http.Handler) http.Handler

RequireAdmin denies with 403 a caller that may not administer: a non-admin account, or — when the request was authenticated by an API token — a token that does not grant the admin scope or is confined to a folder (see CallerMayAdminister). A signed-in administrator's session is judged by the account alone, as it always was.

⚠ It can only judge a token it can see, so the route group must attach the token to the context: MiddlewareWithToken does, and so does Middleware since the api-token driver reports its token to the chain (TokenAuthenticator).

func RequireAdminToken

func RequireAdminToken(next http.Handler) http.Handler

RequireAdminToken is the gate for the token-only admin surface (/api/ai/admin), where the token's scope — not the account's role — is what authorizes (the handlers there elevate the bound principal). It replaces RequireScope("admin") so a confined token is refused too. Must run after APITokenMiddleware.

func RequireScope

func RequireScope(scope string) func(http.Handler) http.Handler

RequireScope rejects requests whose token does not grant `scope`. A token grants only what its list names; an empty list grants nothing. Must run after APITokenMiddleware.

func RequireVerb added in v0.49.0

func RequireVerb(verb string) func(http.Handler) http.Handler

RequireVerb is RequireScope for the routes that accept BOTH a session and a token (MiddlewareWithToken): a session passes, a token must hold verb.

⚠ RequireScope refuses a request with no token at all, which is right on the token-only /api/ai surface and wrong here — it would lock the web explorer out of its own routes.

func ResolveAccount added in v0.50.0

func ResolveAccount(ctx context.Context, l identity.Lookup, identifier string) (*model.User, error)

ResolveAccount turns what a person typed into an account, inside the sign-in's realm when there is one (identity.ResolveIn) and exactly as identity.Resolve otherwise. Every password sign-in path uses it.

func SSOReason added in v0.50.0

func SSOReason(err error) string

SSOReason is the code the sign-in page may show for a failed SSO sign-in, or "" for the one generic answer (see the block comment above): an error wrapped by SSORefused, or a first-login refusal for auto_create / allowed_groups. Anything else, including an account of another tenant, is "".

func SSORefused added in v0.50.0

func SSORefused(reason string, err error) error

SSORefused wraps err with a reason code the sign-in page may show.

func ScopeForUser

func ScopeForUser(ctx context.Context, store db.Store, u *model.User) *tenant.Scope

ScopeForUser resolves a user's tenant scope. Callers that authenticate outside the middleware chain — /dav does its own Basic auth — use this to attach the same scope the HTTP middleware would have.

ALWAYS returns a non-nil scope so the scoped store filters. A user whose provider is missing or unknown gets tenant.DenyAll (sees nothing) — fail closed, never a silent fall-through to "see everything".

func SetAuditAction added in v0.50.0

func SetAuditAction(ctx context.Context, action, targetType string)

SetAuditAction files the request's audit row under another action (and target type) than its route maps to - for a generic route whose write, this time, belongs to a family of its own: a sign-in security setting written through the settings API is a `login_security.update` like the Sign-in security page's. The door rule still applies (DoorAction). A no-op when the request is not being audited.

func SetAuditTarget

func SetAuditTarget(ctx context.Context, id, name string)

SetAuditTarget names the thing the request acted on, for the audit row the middleware writes: id fills target_id when the route did not carry one, name is stored as metadata["target_name"].

⚠⚠ Why: the Panel and the Audit page printed the KIND of thing and nothing else — "Kullanıcı: oluşturuldu — Kullanıcı", "Depo: oluşturuldu — Depo" (release-candidate sweep, 2026-09-21) — because a create's URL has no id and the middleware never reads bodies. The handler knows what it made; this is how it says so. The name is kept because it is what a reader wants AFTER the thing is gone ("which user was deleted?"). A name here is a label — an e-mail, a storage name, a path — never a secret.

func SetEnabled

func SetEnabled(drivers []Driver)

SetEnabled stores the active driver chain (ordered) for the middleware.

func SetLegacyAccountAlarm added in v0.50.0

func SetLegacyAccountAlarm(f func(context.Context, LegacyAccountElsewhere))

SetLegacyAccountAlarm registers how the platform operator hears about an older account in another tenant (server.go: a notification only a platform administrator's bell carries). It is called once per older account, ever — the audit row AuditLegacyAccountElsewhere is the mark. nil unregisters.

func SetPermissionStore added in v0.49.0

func SetPermissionStore(store db.Store)

SetPermissionStore gives the session middleware the store it resolves permission-rule settings from. Unset (tests), Require 2FA is not enforced at the session layer.

func SkipAuditRow added in v0.51.0

func SkipAuditRow(ctx context.Context)

SkipAuditRow says the handler has recorded this request itself, so the AuditMiddleware that records the request, or the admin MCP tools' in-process copy of it (handlers.AIAdmin.auditInvoke), writes no generic row beside its own. A no-op when the request is not being audited.

It is ActionForPath's skip for a door the URL cannot tell apart: PATCH /api/admin/settings carries e2e.policy only sometimes. When the policy is all a request changed, internal/e2epolicy's e2e_policy.update row, with the value before and after, is the record, and a bare settings.update beside it would be a second row that says less (handlers/settings.go).

One request has one recorder: an AuditMiddleware nested inside another steps aside and leaves the row to the outer one (AuditMiddleware). On /api/ai/admin that is the /api/ai group's, the only one it passes (api/routes.go), so the mark reaches the recorder that writes the request's only row, and the handler's own row is the one left.

func SourceOf added in v0.52.0

func SourceOf(driver string) string

SourceOf is the users.auth_source (model.AuthSource*) of an account an auth driver made: "ldap" → LDAP, a proxy-header driver → Proxy, an OpenID Connect driver → SSO; anything else is Local.

func SplitList added in v0.50.0

func SplitList(s string) []string

SplitList splits a comma list into its trimmed, non-empty members.

func StampTokenDoor added in v0.50.0

func StampTokenDoor(ctx context.Context, meta map[string]interface{}, via string) map[string]interface{}

StampTokenDoor adds to meta (allocating it when needed) which token acted - `token_id`, `token_username` - and the door (`via`), when the request was authenticated by a token. A session's row is returned unchanged.

func TellsRefusal added in v0.50.0

func TellsRefusal(cfg map[string]any) bool

TellsRefusal reads ShowRefusalReasonKey from a provider configuration.

func TenantResolver

func TenantResolver(store db.Store, multiTenant bool) func(http.Handler) http.Handler

TenantResolver derives the request's tenant (provider) scope from the authenticated user's provider_id and stashes it in the context for the scoped store and storage confinement to read. It is a **no-op when multi-tenant mode is off** — no scope is set, and callers treat the absence as "unscoped", so single-tenant behaviour is byte-unchanged. Mount it AFTER the auth middleware so the user is already resolved.

The user's own provider_id — not the request Host — is the source of truth for scoping: a user belongs to exactly one tenant regardless of which host they reach. (Host binding drives which OIDC realm authenticates a *login*; that is the login layer's concern, not request scoping.)

A supertenant scope carries IsSupertenant + no StorageIDs; it is confine-exempt and cross-tenant (see docs/MULTI-TENANCY.md).

func TokenAllows added in v0.49.0

func TokenAllows(ctx context.Context, verb string) bool

TokenAllows reports whether the request may use verb: always for a request with no API token on its context, and for a token when its list names verb.

func TokenFrom

func TokenFrom(ctx context.Context) *model.APIToken

TokenFrom returns the API token from ctx, nil if the request was not authenticated by an API token (e.g. a cookie session).

func TokenIsConfined

func TokenIsConfined(tok *model.APIToken) bool

TokenIsConfined reports whether tok carries a `root:` confinement scope.

⚠ Syntactic on purpose: ANY `root:` entry counts, well-formed or not. A scope the confinement parser cannot read (`root:projects`, no storage) would otherwise make the token look unconfined here — the one direction a mistake in an authorization check must not fall.

func TokenMayAdminister

func TokenMayAdminister(tok *model.APIToken) bool

TokenMayAdminister reports whether a token is an operator credential: it grants the admin scope and is not confined. It says nothing about the account the token is bound to — see CallerMayAdminister for that.

func TokenMayBeUsed added in v0.49.0

func TokenMayBeUsed(ctx context.Context, store db.Store, u *model.User, tok *model.APIToken) bool

TokenMayBeUsed reports whether u may authenticate with tok. A resolution error refuses — a credential that cannot be judged is not accepted.

func TokenPermission added in v0.49.0

func TokenPermission(tok *model.APIToken) perm.Perm

TokenPermission is the permission using tok needs.

func TokenUserFrom

func TokenUserFrom(ctx context.Context) string

TokenUserFrom returns the identity a token-authenticated request acts under ("" for cookie sessions). Set by the token middlewares alongside WithToken.

func TwoFactorPending added in v0.49.0

func TwoFactorPending(ctx context.Context, store db.Store, u *model.User) bool

TwoFactorPending reports whether u is bound by a Require 2FA rule and has not enrolled.

func TwoFactorPendingAllowed added in v0.49.0

func TwoFactorPendingAllowed(path string) bool

TwoFactorPendingAllowed reports whether a pending session may reach path.

func UserFrom

func UserFrom(ctx context.Context) *model.User

UserFrom returns the user from ctx, nil if absent.

func WithLoginHost

func WithLoginHost(ctx context.Context, host string) context.Context

WithLoginHost stamps the Host of the HTTP request that started a login onto the context.

⚠ It exists because auth.LoginDriver.Login takes only a ctx — no *http.Request — so a driver in the login chain cannot see the host by itself. Widening the interface would touch every driver and every protocol caller for the benefit of one; the context carries it instead, and a login that arrives without one (a protocol login) simply has no host, which is the truth.

func WithLoginRealm added in v0.50.0

func WithLoginRealm(ctx context.Context, lr *LoginRealm) context.Context

WithLoginRealm stamps the sign-in's realm on the context, for the login chain's drivers (auth.LoginDriver.Login takes only a ctx — see WithLoginHost for why the context carries it). A nil realm leaves ctx as it is.

func WithProbeLang added in v0.50.0

func WithProbeLang(ctx context.Context, lang string) context.Context

WithProbeLang stamps the language a provider test words its hints in.

func WithRequestLoginHost

func WithRequestLoginHost(ctx context.Context, r *http.Request) context.Context

WithRequestLoginHost is WithLoginHost for an *http.Request.

func WithTestAccount added in v0.50.0

func WithTestAccount(ctx context.Context, t *TestAccount) context.Context

WithTestAccount stamps the test account on ctx. A nil account, or one without a name, is not stamped.

func WithToken

func WithToken(ctx context.Context, t *model.APIToken) context.Context

WithToken stores the matched API token on ctx, and notes its id (never the secret) on the request's access-log holder, like WithUser does the account.

func WithTokenUser

func WithTokenUser(ctx context.Context, username string) context.Context

WithTokenUser stores the resolved token username on ctx.

func WithUser

func WithUser(ctx context.Context, u *model.User) context.Context

WithUser stores u on ctx.

It also notes the account on the request's access-log holder, when there is one (httpx.RequestLog): every door that authenticates a request passes through here, so the access log learns who asked without each of them having to remember to say so.

Types

type AdoptStore added in v0.50.0

type AdoptStore interface {
	GetUserByEmail(ctx context.Context, email string) (*model.User, error)
	GetUser(ctx context.Context, id int64) (*model.User, error)
	UpdateUserEmail(ctx context.Context, id int64, email string) error
	GetProvider(ctx context.Context, id int64) (*model.Provider, error)
	GetProviderByHost(ctx context.Context, host string) (*model.Provider, error)
	GetProviderBySlug(ctx context.Context, slug string) (*model.Provider, error)
	InsertAuditEntry(ctx context.Context, e *model.AuditEntry) error
	ListAuditFiltered(ctx context.Context, userID *int64, action string, from, to *time.Time, limit, offset int) ([]*db.AuditEntryWithUser, int64, error)
}

AdoptStore is the slice of db.Store AdoptAccount needs.

type Adoption added in v0.50.0

type Adoption struct {
	// Driver names the provider, for the log and the audit row.
	Driver string
	// LoginName is the name the provider knew the person by — what the older
	// path used as the e-mail. One that contains `@` adopts nothing: the older
	// path kept an address as it was, so there is no older key to find.
	LoginName string
	// Email is the address the account has under the rule.
	Email string
	// Homing is the provider's tenant policy: on a multi-tenant install only an
	// account in the sign-in's tenant is adopted.
	Homing TenantHoming
}

Adoption is one sign-in that may take over an older account.

type AuditDetail

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

AuditDetail is what a handler adds to the audit row the middleware writes for its request: the facts only the handler knows (which fields changed, before and after) — so a change is ONE row that says what it did, not a bare "auth_provider.update".

⚠ Names and flags only. Nothing a handler puts here may be a secret: the audit log is read by every administrator and exported.

func WithAuditDetail

func WithAuditDetail(ctx context.Context) (context.Context, *AuditDetail)

WithAuditDetail returns a context carrying an empty detail holder.

func (*AuditDetail) Action added in v0.50.0

func (d *AuditDetail) Action() (string, string)

Action answers SetAuditAction's rename - action and target type; "" when there is none. Nil-safe.

func (*AuditDetail) ApplyTarget

func (d *AuditDetail) ApplyTarget(targetID string, meta map[string]interface{}) (string, map[string]interface{})

ApplyTarget folds SetAuditTarget's answer into a row: the id only where the route gave none, the name into meta. Nil-safe.

func (*AuditDetail) Into

func (d *AuditDetail) Into(meta map[string]interface{}) map[string]interface{}

Into merges the recorded facts into meta (allocating it when needed).

func (*AuditDetail) Skipped added in v0.51.0

func (d *AuditDetail) Skipped() bool

Skipped reports whether the handler recorded its request itself (SkipAuditRow). Nil-safe.

type Capabilities

type Capabilities struct {
	SignIn         bool `json:"sign_in"`
	Logout         bool `json:"logout"`
	ChangePassword bool `json:"change_password"`
	Register       bool `json:"register"`
}

Capabilities advertises what an auth driver supports — used by the UI to decide whether to render Login / Register / ChangePassword buttons.

type DirectorySyncReport added in v0.52.0

type DirectorySyncReport struct {
	Provider   string    `json:"provider"`
	Trigger    string    `json:"trigger"` // "manual" or "schedule"
	StartedAt  time.Time `json:"started_at"`
	FinishedAt time.Time `json:"finished_at"`
	// Found is how many people the directory search returned with an
	// e-mail; Created how many of them had no account yet.
	Found   int `json:"found"`
	Created int `json:"created"`
	// Updated is how many accounts' directory-linked group memberships
	// changed.
	Updated int `json:"updated"`
	// Skipped: an entry with no e-mail, or an account that could not be
	// made (another tenant's address, no tenant to home it in).
	Skipped int `json:"skipped"`
	// Missing: accounts the directory made that it no longer lists. Their
	// directory memberships end.
	Missing int `json:"missing"`
	// Disabled: accounts switched off — their person switched off in the
	// directory, or no longer listed (sync_disable_missing). Enabled:
	// accounts sync had switched off, on again because the directory let
	// the person back in.
	Disabled int `json:"disabled"`
	Enabled  int `json:"enabled"`
	// EmailsChanged: accounts whose e-mail followed a change in the
	// directory (found by the person's permanent id).
	EmailsChanged int `json:"emails_changed"`
	// Groups (sync_groups): how many the directory listed, how many became
	// new filex groups, were renamed with their directory group, came back
	// after being removed, were flagged removed, or were skipped because a
	// filex group already links to them by hand.
	GroupsFound        int `json:"groups_found"`
	GroupsCreated      int `json:"groups_created"`
	GroupsRenamed      int `json:"groups_renamed"`
	GroupsRestored     int `json:"groups_restored"`
	GroupsRemoved      int `json:"groups_removed"`
	GroupsLinkedByHand int `json:"groups_linked_by_hand"`
	// Problems names up to MaxSyncProblems entries that went wrong; Error
	// is set when the run stopped.
	Problems []string `json:"problems,omitempty"`
	Error    string   `json:"error,omitempty"`
}

DirectorySyncReport is what one directory sync did.

func (*DirectorySyncReport) Problem added in v0.52.0

func (r *DirectorySyncReport) Problem(who string, err error)

Problem records one entry that went wrong.

type DirectorySyncer added in v0.52.0

type DirectorySyncer interface {
	// SyncDirectory makes the accounts and the directory-linked group
	// memberships match the directory. A failure that stops the whole run
	// is returned as the error, with the report of what was done before it.
	SyncDirectory(ctx context.Context) (*DirectorySyncReport, error)
	// SyncInterval is how often to run it on its own; 0 is never.
	SyncInterval() time.Duration
}

DirectorySyncer is a provider that can read its whole directory — every person and their groups — without anybody signing in: the LDAP driver's directory sync (docs/LDAP.md → Directory sync). authsetup runs it on the provider's interval and when an administrator presses Sync now.

type Driver

type Driver interface {
	Name() string
	Init(ctx context.Context, cfg map[string]any) error
	Authenticate(r *http.Request) (*model.User, error)
	Capabilities() Capabilities
}

Driver is the abstract authentication backend.

Authenticate is called by the auth middleware for every protected request and must be cheap. Drivers should NOT block on network IO during Authenticate — heavy work belongs in Init or in callbacks.

func Enabled

func Enabled() []Driver

Enabled returns the configured driver chain.

func Get

func Get(name string) (Driver, error)

Get returns a freshly-constructed driver instance, error if unknown.

type Factory

type Factory func() Driver

Factory builds a fresh Driver instance — invoked once per server start.

type FirstLogin added in v0.50.0

type FirstLogin struct {
	// Driver names the provider, for the log and the audit row.
	Driver string
	// Identifier is what the person typed or the provider named them by; it is
	// a label for the operator (audit/log), never used to look anything up.
	Identifier string
	Email      string
	Role       string
	// Groups are the groups the provider reported; empty when it reports none.
	Groups []string
	Policy FirstLoginPolicy
	// Homing places the account in a tenant on a multi-tenant install (host,
	// then pin). ProviderID, when non-zero, is a tenant the provider instance is
	// already bound to (a per-tenant OIDC realm) and wins over Homing.
	Homing     TenantHoming
	ProviderID int64
	// OIDCSubject is stamped on the account when the provider has one.
	OIDCSubject string
}

FirstLogin is one first sign-in: who, through which provider, with which groups, under which policy.

type FirstLoginPolicy added in v0.50.0

type FirstLoginPolicy struct {
	// AutoCreate: open an account at the first sign-in. The zero value is
	// false; providers get the default (true) from FirstLoginPolicyFrom.
	AutoCreate bool
	// AllowedGroups: when non-empty, only members of at least one of these
	// groups get an account. Empty = no group condition.
	AllowedGroups []string
}

FirstLoginPolicy is a provider's setting for people with no account yet.

func FirstLoginPolicyFrom added in v0.50.0

func FirstLoginPolicyFrom(cfg map[string]any) FirstLoginPolicy

FirstLoginPolicyFrom reads a provider configuration: `auto_create` (bool, default TRUE — an upgrade must not leave anybody outside), `allowed_groups` (comma list). `auto_provision` is read as the older name of auto_create, for the header proxy's configurations that predate it.

func (FirstLoginPolicy) Decide added in v0.50.0

func (p FirstLoginPolicy) Decide(groups []string) string

Decide answers a first sign-in with the reason it is refused, or "" to let it through. groups are the ones the provider reported for the person.

type FirstLoginRefusal added in v0.50.0

type FirstLoginRefusal struct {
	Reason string
}

FirstLoginRefusal is the error a refused first sign-in returns.

func (*FirstLoginRefusal) Error added in v0.50.0

func (e *FirstLoginRefusal) Error() string

func (*FirstLoginRefusal) Is added in v0.50.0

func (e *FirstLoginRefusal) Is(target error) bool

Is makes errors.Is(err, ErrFirstLoginRefused) true.

type HandoffPurpose added in v0.50.0

type HandoffPurpose string

HandoffPurpose names what a ticket may be redeemed for.

const (
	// HandoffLogin finishes, on a tenant's own address, a sign-in made on the
	// platform's address. Actor and subject are the same account.
	HandoffLogin HandoffPurpose = "login_handoff"
)

type HandoffRefusal added in v0.50.0

type HandoffRefusal struct{ Reason string }

HandoffRefusal is the error a refused ticket answers with.

func (*HandoffRefusal) Error added in v0.50.0

func (e *HandoffRefusal) Error() string

func (*HandoffRefusal) Is added in v0.50.0

func (e *HandoffRefusal) Is(target error) bool

Is makes errors.Is(err, ErrHandoffRefused) true.

type HandoffStore added in v0.50.0

type HandoffStore struct {
	// Now is the clock; nil = time.Now. Tests move it.
	Now func() time.Time
	// contains filtered or unexported fields
}

HandoffStore holds the tickets in flight.

func NewHandoffStore added in v0.50.0

func NewHandoffStore() *HandoffStore

NewHandoffStore builds an empty store.

func (*HandoffStore) Issue added in v0.50.0

func (s *HandoffStore) Issue(t HandoffTicket, ttl time.Duration) (string, error)

Issue stores t for ttl and returns its code: 32 random bytes, URL-safe. The code is returned once and never kept.

func (*HandoffStore) Redeem added in v0.50.0

func (s *HandoffStore) Redeem(code string, purpose HandoffPurpose, host string) (*HandoffTicket, error)

Redeem spends the ticket behind code and returns it when it may be used for purpose on host. A ticket that exists is spent by this call whatever it answers; the ticket is returned with a refusal too (nil only for an unknown code), so the caller can audit whose ticket was refused.

type HandoffTicket added in v0.50.0

type HandoffTicket struct {
	Purpose HandoffPurpose
	// ActorID is the account that caused the ticket; SubjectID the account
	// the session is for. Equal for HandoffLogin.
	ActorID   int64
	SubjectID int64
	// Host is the only request host the ticket is redeemed on (lower case, no
	// port); ProviderID the tenant that host must name.
	Host       string
	ProviderID int64
	IssuedAt   time.Time
	ExpiresAt  time.Time
}

HandoffTicket is one grant to open a session on one host.

type LegacyAccountElsewhere added in v0.50.0

type LegacyAccountElsewhere struct {
	// Driver is the provider the person signed in through.
	Driver string
	// Account is the login name — the older account's e-mail.
	Account string
	// UserID is the older account.
	UserID int64
	// AccountTenant is the older account's tenant (its slug); LoginTenant the
	// tenant the sign-in was for.
	AccountTenant string
	LoginTenant   string
}

LegacyAccountElsewhere is what the platform operator is told about an older account found in another tenant than the sign-in's. Names only — no password, no token, no group.

type LoginChain

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

LoginChain tries a password against several login drivers in turn.

Why this type exists

Before it, the login handler held ONE LoginDriver and the server bootstrap only ever assigned the `local` driver to it. A directory driver could be configured, initialised, and listed in the boot banner while its Login method was unreachable from any code path — `FILEX_AUTH_DRIVERS=local,ldap` produced a server that said "Auth: ldap, local" and answered every directory account with 401 in ~350us, which is less than one LDAPS round trip. The docs meanwhile described exactly the behaviour this type implements ("filex tries each enabled driver in order"), so the gap read as a directory misconfiguration to everyone who hit it.

Order is the contract

Drivers are tried in the order the operator listed them in `auth.drivers` / FILEX_AUTH_DRIVERS. Keeping `local` first matters: it is a bcrypt compare against a row that is already local, so the built-in admin@local account and every break-glass password stay answerable when the directory is unreachable. A directory driver placed first would put a network round trip in front of every local login and, on an unreachable directory, in front of the one login that is supposed to still work.

func NewLoginChain

func NewLoginChain(drivers ...LoginDriver) *LoginChain

NewLoginChain returns a chain over drivers, in order. Nil drivers are dropped so a caller can pass an optional driver without a nil check.

func (*LoginChain) Len

func (c *LoginChain) Len() int

Len reports how many drivers the chain will try. Used by the bootstrap to avoid wrapping a single driver in a chain for nothing.

func (*LoginChain) Login

func (c *LoginChain) Login(ctx context.Context, identifier, password string) (*model.User, string, error)

Login tries each driver in order and returns the first success.

A driver that answers ErrUnauthorized has judged the credentials and said no; the chain moves on, because "not my user" and "wrong password" are the same answer by design (no account enumeration). Any OTHER error means the driver could not judge at all — an unreachable directory, a TLS failure, a bad service-account bind — and that is NOT swallowed: it is logged with the driver name and returned when no later driver succeeds, so an operator reading the log can tell "wrong password" from "the directory is down".

A refusal that carries a reason the person may be told (RefusedAfterPassword, a provider whose show_refusal_reason is on) is still a "no", but it is a judgement about THIS person: the first one is what the chain answers when no later driver signs them in, ahead of a driver that could not judge.

func (*LoginChain) Logout

func (c *LoginChain) Logout(ctx context.Context, token string) error

Logout revokes the session on every member.

Every login driver mints its session in the same sessions table (that is what makes one cookie work regardless of which driver authenticated), so revoking on all of them is idempotent rather than wasteful — and it stays correct if a future driver keeps state of its own. The first error is reported; the remaining drivers still run, because a half-revoked session is the one outcome worth avoiding.

func (*LoginChain) Name

func (c *LoginChain) Name() string

Name reports the chain and its members, so a boot line or a log entry names the drivers a password will actually be tried against — the thing that was impossible to see from the outside before.

type LoginDriver

type LoginDriver interface {
	Login(ctx context.Context, email, password string) (*model.User, string, error) // user, sessionToken, err
	Logout(ctx context.Context, token string) error
}

LoginDriver is an optional capability — drivers that accept username + password directly (i.e. local) implement it.

type LoginRealm added in v0.50.0

type LoginRealm struct {
	// Tenant is the tenant. The supertenant for the platform's own realm; nil
	// only on a platform with no supertenant row (then only accounts with no
	// tenant belong to the main realm).
	Tenant *model.Provider
	// Named: a realm was typed, or the address named the tenant. False: the
	// platform's own realm because nothing named another.
	Named bool
	// Typed is the realm as typed, normalised ("" when none) — kept for the
	// sign-in limit's counter even when it names nobody.
	Typed string
	// Token is the installation's e-mail token, for derived addresses.
	Token string
	// contains filtered or unexported fields
}

LoginRealm is the tenant one sign-in is for.

func LoginRealmFrom added in v0.50.0

func LoginRealmFrom(ctx context.Context) *LoginRealm

LoginRealmFrom returns the stamped realm, or nil (single-tenant, or a caller that is not a sign-in).

func ResolveLoginRealm added in v0.50.0

func ResolveLoginRealm(ctx context.Context, store RealmStore, typed string, named bool, host, token string) (*LoginRealm, error)

ResolveLoginRealm decides which tenant a sign-in is for. typed is the realm the person wrote and named whether they wrote one at all (`/alex` names the platform's own realm outright); host is the address the client reached ("" for none).

The returned LoginRealm is non-nil even with an error, so the caller can still count the attempt under the realm that was typed.

func (*LoginRealm) Admits added in v0.50.0

func (lr *LoginRealm) Admits(u *model.User) bool

Admits reports whether an account may be signed in to through this realm: it belongs to the tenant — or, for the platform's own realm reached without naming it, to the tenant a directory provider's pin homed the sign-in in.

func (*LoginRealm) AdmitsCredential added in v0.50.0

func (lr *LoginRealm) AdmitsCredential(u *model.User) bool

AdmitsCredential is Admits for a credential that names its account by itself — an API token, a registered SSH key: no realm is needed, so only a realm that WAS named (typed, or by the address) has to be the account's.

func (*LoginRealm) CounterRealm added in v0.50.0

func (lr *LoginRealm) CounterRealm() string

CounterRealm is the realm the sign-in limit counts this attempt under (loginguard.Attempt.Realm): what was typed, else the tenant the address named, else none. `acme/alex`, `alex` at acme's address and `alex` with Realm "acme" are one counter; `beta/alex` and `alex` are others.

func (*LoginRealm) DeriveRealm added in v0.50.0

func (lr *LoginRealm) DeriveRealm() string

DeriveRealm is the realm derived addresses are made in (identity.DeriveEmail): the tenant's, "" for the platform's own.

func (*LoginRealm) Identity added in v0.50.0

func (lr *LoginRealm) Identity() *identity.Tenant

Identity is the lookup restriction identity.ResolveIn applies. nil for a nil LoginRealm (no tenants).

func (*LoginRealm) NotePinned added in v0.50.0

func (lr *LoginRealm) NotePinned(providerID int64)

NotePinned records that a directory provider homed this un-named sign-in in a tenant by its `provider` pin (TenantHoming.Pin). Called by the provider that signed the person in, after it has: the pin is the operator saying "realm-less sign-ins of this directory belong to that tenant", and Admits honours it for this sign-in only.

func (*LoginRealm) PinnedTenant added in v0.50.0

func (lr *LoginRealm) PinnedTenant() int64

PinnedTenant is the tenant NotePinned recorded for this sign-in, 0 for none.

type OIDCDriver

type OIDCDriver interface {
	StartFlow(w http.ResponseWriter, r *http.Request) error
	HandleCallback(w http.ResponseWriter, r *http.Request) (*model.User, string, error)
}

OIDCDriver is an optional capability — drivers that perform browser redirects to an external IdP implement it.

type OIDCLogoutDriver

type OIDCLogoutDriver interface {
	EndSessionURL(r *http.Request, idToken, postLogoutRedirect string) string
}

OIDCLogoutDriver is an optional capability of an OIDCDriver: RP-initiated logout (OpenID Connect RP-Initiated Logout 1.0). EndSessionURL returns where to send the browser so the IdP ends its own session too — idToken is the one kept for the session being signed out — or "" to keep sign-out local.

type ProbeCheck

type ProbeCheck struct {
	ID     string            `json:"id"`
	Status string            `json:"status"`
	Params map[string]string `json:"params,omitempty"`
}

ProbeCheck is one step of a provider test. ID names the step (the panel words it in the reader's language); Params carries what the sentence needs — a host, a field list, a reason, the technical detail.

func Check

func Check(id, status string, kv ...string) ProbeCheck

Check builds a ProbeCheck from key/value pairs.

func FirstLoginCheck added in v0.50.0

func FirstLoginCheck(cfg map[string]any, groupsFrom string) ProbeCheck

FirstLoginCheck is the provider test's step for the first-login rule. Its ID says what the configuration does — first_login_open (every new person gets an account), first_login_groups (only members of allowed_groups), first_login_closed (nobody new does) — and the step FAILS (`first_login`, reason no_group_source) when the configuration cannot work: allowed_groups with nowhere to read groups from (groupsFrom == "", e.g. OIDC without a role/group claim, which would refuse everybody).

type Prober

type Prober interface {
	Probe(ctx context.Context, cfg map[string]any, r *http.Request) []ProbeCheck
}

Prober is a driver that can test a configuration without anyone signing in. cfg is the configuration to test (the form's values over the stored ones), not the running driver's; r is the administrator's own request, which the proxy-header driver examines.

type ProvisionStore

type ProvisionStore interface {
	CreateUser(ctx context.Context, email, passwordHash, role, locale, tz string) (*model.User, error)
	DeleteUser(ctx context.Context, id int64) error
	GetUser(ctx context.Context, id int64) (*model.User, error)
	SetUserProvider(ctx context.Context, userID, providerID int64, oidcSubject string) error
	SetUserAuthSource(ctx context.Context, userID int64, source string) error
	GetProviderByHost(ctx context.Context, host string) (*model.Provider, error)
	GetProviderBySlug(ctx context.Context, slug string) (*model.Provider, error)
}

ProvisionStore is the slice of db.Store just-in-time provisioning needs.

type RealmStore added in v0.50.0

type RealmStore interface {
	GetProviderByRealm(ctx context.Context, realm string) (*model.Provider, error)
	GetProviderByHost(ctx context.Context, host string) (*model.Provider, error)
	GetSupertenant(ctx context.Context) (*model.Provider, error)
}

RealmStore is the slice of db.Store realm resolution needs.

type SSORefusalError added in v0.50.0

type SSORefusalError struct {
	Reason string
	Err    error
}

SSORefusalError is a failed SSO sign-in whose reason the sign-in page may show. Error() is the wrapped error's, so the log line does not change.

func (*SSORefusalError) Error added in v0.50.0

func (e *SSORefusalError) Error() string

func (*SSORefusalError) Unwrap added in v0.50.0

func (e *SSORefusalError) Unwrap() error

Unwrap keeps errors.Is / errors.As working on the wrapped error.

type StrictProber added in v0.50.0

type StrictProber interface {
	Prober
	// StrictProbe reports that a failing test can never be confirmed away.
	StrictProbe() bool
}

StrictProber is a Prober whose PASSING test is a condition of switching the provider on: the operator cannot override a failing test with confirm_failed_test. It is for providers whose only proof of working is a real sign-in — one that was never tried would otherwise be switched on blind.

type TenantHoming

type TenantHoming struct {
	// MultiTenant mirrors cfg.MultiTenant. False = do nothing at all.
	MultiTenant bool
	// Pin is an operator-declared provider SLUG, used when the login carries no
	// host that maps to a tenant.
	//
	// ⚠ It exists for the logins that structurally have no Host: SFTP, FTPS and
	// NFS present a password on a socket, not an HTTP request. Without it, a
	// multi-tenant install simply cannot provision a directory user over those
	// protocols — which is a defensible default (the account still works the
	// moment it has logged in through the web once) but a bad surprise, so the
	// operator gets a way to say it out loud.
	Pin string
}

TenantHoming is a driver's policy for where its JIT accounts land.

func (TenantHoming) DirectoryRealm added in v0.50.0

func (h TenantHoming) DirectoryRealm(ctx context.Context, store providerLookup) (realm string, home *model.Provider, viaPin bool)

DirectoryRealm is the realm a directory provider derives addresses in for this sign-in: the realm of the tenant Home names, "" when it names none (the platform's own tenant, a single-tenant install). viaPin reports that the driver's pin chose the tenant — call NoteHome once the person is signed in.

func (TenantHoming) Home added in v0.50.0

func (h TenantHoming) Home(ctx context.Context, store providerLookup) (*model.Provider, bool, error)

Home is the tenant a directory account of this sign-in belongs in, and whether the operator's pin is what said so:

  1. the realm the sign-in NAMED (typed, or the tenant's own address — LoginRealm); the platform's own realm named outright homes nobody (a directory account is never made in the supertenant, see ErrNoTenantForLogin);
  2. the login's host (a caller that stamped only WithLoginHost);
  3. the driver's `provider` pin;

else ErrNoTenantForLogin. Nothing here on a single-tenant install (MultiTenant false): (nil, false, nil).

⚠ The tenant it names is also the realm a directory account's derived address is made in (`alex@acme.local`, identity.DeriveEmail): an account always carries its own tenant's realm, however the sign-in found it.

type TestAccount added in v0.50.0

type TestAccount struct {
	Username string
	Password string

	// Email is set by the prober when the account signed in and passed the
	// provider's own rules: the address the account has (or would get) in
	// filex. Empty means the test did not get that far.
	Email string
}

TestAccount is the account an administrator gives a provider test. It travels as a POINTER: the prober that signs it in records who it turned out to be (Email) on the same value the handler reads afterwards.

func ParseTestAccount added in v0.50.0

func ParseTestAccount(raw map[string]any) (*TestAccount, bool)

ParseTestAccount reads `test_account` {username, password} from a request body. The name is trimmed, the password is taken as it is (a space can be part of one). ok is false when the body has no account with a name.

func TestAccountFrom added in v0.50.0

func TestAccountFrom(ctx context.Context) *TestAccount

TestAccountFrom returns the stamped test account, or nil.

func (TestAccount) GoString added in v0.50.0

func (t TestAccount) GoString() string

GoString keeps %#v from printing the password either.

func (TestAccount) String added in v0.50.0

func (t TestAccount) String() string

String never shows the password (a %v in a log line would otherwise print it).

type TestAccountGranter added in v0.50.0

type TestAccountGranter interface {
	GrantTestAccount(ctx context.Context, store any, cfg map[string]any, username string) error
}

TestAccountGranter is a provider that makes the account its test signed in with a super administrator itself (created, or raised) — for one whose account addresses or homing are its own business. Providers without it get the generic GrantSuperAdmin on TestAccount.Email. The handler calls it once the provider is saved and running.

type TokenAuthenticator

type TokenAuthenticator interface {
	AuthenticateToken(r *http.Request) (*model.User, *model.APIToken, error)
}

TokenAuthenticator is implemented by a Driver whose credential IS an API token (drivers/apitoken). The chain calls it instead of Authenticate so the matched token reaches the request context together with the user.

⚠ Why this exists: the api-token driver is always enabled, so a token authenticates on any route group built on Middleware — and the driver used to hand back the user and drop the token. Every scope check downstream then saw a request with no token at all and judged it by the account's role, which made a read-only token minted on an administrator's account a full administrator credential on the admin routes.

Directories

Path Synopsis
drivers
apitoken
Package apitoken implements bearer-token authentication for non-interactive callers: AI agents, the work.example.com FilexClient, and the embedded MCP server.
Package apitoken implements bearer-token authentication for non-interactive callers: AI agents, the work.example.com FilexClient, and the embedded MCP server.
ldap
Package ldap implements simple-bind LDAP/Active Directory authentication.
Package ldap implements simple-bind LDAP/Active Directory authentication.
local
Package local implements username + password authentication backed by the users table.
Package local implements username + password authentication backed by the users table.
multioidc
Package multioidc dispatches OIDC flows to per-tenant IdP realms (docs/MULTI-TENANCY.md §5,§7).
Package multioidc dispatches OIDC flows to per-tenant IdP realms (docs/MULTI-TENANCY.md §5,§7).
oidc
Package oidc implements OpenID Connect authentication.
Package oidc implements OpenID Connect authentication.
pam
Package pam is the Linux operating-system sign-in provider: a person signs in to filex with the login they use on the machine filex runs on, and PAM — the same door SSH and su use — judges the password.
Package pam is the Linux operating-system sign-in provider: a person signs in to filex with the login they use on the machine filex runs on, and PAM — the same door SSH and su use — judges the password.
proxyheader
Package proxyheader implements authentication via headers injected by a trusted upstream reverse proxy (nginx, Caddy, oauth2-proxy, Cloudflare Access, Authelia, …).
Package proxyheader implements authentication via headers injected by a trusted upstream reverse proxy (nginx, Caddy, oauth2-proxy, Cloudflare Access, Authelia, …).
windows
Package windows signs people in with their Windows account: a local account of the machine filex runs on, or a domain account of the domain it is joined to.
Package windows signs people in with their Windows account: a local account of the machine filex runs on, or a domain account of the domain it is joined to.

Jump to

Keyboard shortcuts

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