auth

package module
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 37 Imported by: 0

README

awesome-go-auth

awesome-go-auth is a Go authentication library with stateful sessions and access/refresh tokens.

Installation

go get github.com/nik2208/awesome-go-auth

Quick start

package main

import (
	"context"
	"log"

	auth "github.com/nik2208/awesome-go-auth"
)

func main() {
	cfg := auth.DefaultConfig("replace-with-at-least-32-random-chars")
	service, err := auth.NewService(cfg, auth.NewMemoryUserStore(), auth.NewMemorySessionStore())
	if err != nil {
		log.Fatal(err)
	}

	_, tokens, err := service.Register(context.Background(), auth.RegisterInput{
		Email:    "alice@example.com",
		Password: "supersecurepassword",
		TenantID: "tenant-1",
	})
	if err != nil {
		log.Fatal(err)
	}

	log.Println("access token issued", tokens.AccessToken != "")
}

Features

  • Secure configuration with validation.
  • Register/login with password hashing (bcrypt).
  • Signed access token + refresh token (HMAC-SHA256) with expiry.
  • Stateful sessions with refresh token rotation and revocation (logout).
  • Configurable session check policy (allcalls / refresh / none).
  • Authenticated user retrieval (Me).
  • Account management (UpdateProfile, DeleteAccount) in addition to password/email lifecycle.
  • CSRF double-submit middleware (cookie + header) for browser flows.
  • Password reset (ForgotPassword, ResetPassword, ChangePassword).
  • Passwordless magic link (SendMagicLink, VerifyMagicLink).
  • SMS OTP login (SendSMSCode, VerifySMSCode).
  • Mail and SMS delivery for both of the above via Config.SendMagicLink / Config.SendSMSCode, with built-in senders over an HTTP mailer or SMS gateway (Delivery).
  • TOTP 2FA (SetupTOTP, VerifyTOTPSetup, VerifyTOTP, DisableTOTP).
  • Email verification (SendVerificationEmailToken, VerifyEmail) and email change (RequestEmailChange, ConfirmEmailChange).
  • Session admin helpers (ListSessions, RevokeSessionByID, CleanupExpiredSessions).
  • User metadata store and service helpers.
  • Multi-tenant in-memory RBAC (MemoryRolesPermissionsStore) with roles and permissions.
  • In-memory tenant store (MemoryTenantStore) with user↔tenant membership.
  • Custom token claims via Config.BuildTokenClaims, written by hand or built from configuration with StaticClaims, UserFieldClaims, ChainClaims and the synchronous ClaimsWebhook; reflected under customClaims on GET /me. The hook may override the reference's six base claims (sub, email, role, loginProvider, isEmailVerified, isTotpEnabled); the session claims sid, tid, jti, typ, iss, iat and exp are reserved. It runs at mint time and on /me, never in the adapters' middleware (Custom Claims).
  • API key service and HTTP middleware (APIKeyService, APIKeyMiddleware).
  • In-process event bus (EventBus) for event-driven integrations.
  • Extended storage interfaces and thread-safe in-memory implementations for all the above flows.

Idiomatic entrypoint and HTTP adapters

  • auth.New(...) entrypoint with functional options (WithSecret, WithTokenTTLs, WithUserStore, WithSessionStore, etc.).
  • Framework-agnostic adapters available:
    • adapter/nethttp (Middleware, Mount, MountWithConfig)
    • adapter/chi (Middleware, Mount, MountWithConfig)
    • adapter/gin (Middleware, Mount, MountWithConfig)
    • adapter/echo (Middleware, Mount, MountWithConfig)
  • All four adapters serve the same wire contract, configured by a single auth.HTTPConfig (mount prefix, cookie policy, CSRF) and written through the shared helpers in wire.go.

HTTP wire conventions

The HTTP surface follows the awesome-node-auth contract the family clients (Angular, Flutter, the served auth.js) are pinned to:

  • GET <prefix>/me returns the user object unwrapped.
  • Cookie mode (default) answers {"success": true} and sets the cookies; X-Auth-Strategy: bearer (exact, case-sensitive) answers with top-level accessToken/refreshToken and sets no cookies.
  • Cookie names resolve to __Host- / __Secure- / bare from the cookie policy, and every read tries __Host- → __Secure- → bare.
  • Errors are {"error": "<message>", "code": "<CODE>"}; a revoked session is 401 {"code": "SESSION_REVOKED"}, the signal both browser clients use to stop refreshing and log out.
  • CSRF is double-submit on X-CSRF-Token, enforced for cookie-authenticated unsafe methods only. There is no /csrf endpoint: the cookie is distributed by the router-level middleware.

Deliberate deviations from the reference

The standing rule is to reproduce awesome-node-auth including its quirks, because the family clients are pinned to it. The entries below are the places this port knowingly does not, each with the reason the rule was set aside.

This section is generated from CompatibilityNotes(), which returns the same register as data — it is not a second copy kept in step by hand. compatibility_test.go re-renders it and compares it against this file, so a deviation cannot be edited on one side only, and cannot be added to the register without appearing here. The test also pins the set of ids and the wire facts each entry has to keep stating, so an entry cannot be quietly hollowed out by someone who does regenerate.

Citations are file:line into awesome-node-auth@cc01e997 (npm 1.9.0), the revision the whole contract was extracted from.

forgot-password: unconditional 200 on delivery failure

forgot-password-succeeds-on-delivery-failure

  • Surface: POST <prefix>/forgot-password.
  • This port: Always 200 {"success": true} — when the mail was sent, when the configured sender returns an error, and when no sender is configured at all. The reset token stays stored in every case. A store failure still answers 500, which is the reference's behaviour and is kept.
  • The reference: The send sits inside the route's try, so a throwing mailer reaches handleError and answers 500 (auth.router.ts:787-798).
  • Why: That 500 fires only for an address that exists, so a broken mail gateway turns the one route whose purpose is to reveal nothing about who is registered into an account-enumeration oracle. The contract records the status as [UNTESTED], and no client can depend on one that appears only when the operator's mailer is down.
  • Observing the failure: Only the HTTP surface swallows it. Auth.ForgotPassword absorbs ErrDeliveryFailed (and nothing else) and logs auth: password reset delivery failed; the route still answered success: …, without naming the address. Service.ForgotPassword still returns the error, so a direct library caller learns about it.
The 2FA step-up token is typed, not an access token

temp-token-is-typed-not-an-access-token

  • Surface: POST <prefix>/login (the tempToken in a 2FA challenge) and the step-up routes that accept it.
  • This port: The step-up token carries its own type. It is accepted by the second-factor routes and by nothing else, and an ordinary access token is not accepted in its place.
  • The reference: Mints the tempToken as an ordinary 5-minute access token with no claim distinguishing it, so it authenticates any protected route for five minutes before the second factor has been presented, and a full access token also passes as a tempToken (auth.router.ts:572-575, token.service.ts:20-24).
  • Why: Reproducing it would mean shipping a five-minute bypass of the second factor the challenge exists to demand. The type claim is not on the wire — the token is opaque to every client — and the reference's own sharing is [UNTESTED] there, so no shipped client depends on it. The cost is that the tempToken in a 2FA_SETUP_REQUIRED answer cannot reach the enrolment routes, which sit behind the access-token gate.

link-request-exempts-bearer-from-csrf

  • Surface: POST <prefix>/link-request.
  • This port: A request carrying a real Authorization: Bearer credential is exempt from the double-submit check, as it is on every other route. Without one the check is enforced whether or not the request is cookie-authenticated.
  • The reference: Gates its hand-written double-submit check on config.csrf.enabled alone, with no usingBearer term — unlike its own auth middleware (auth.middleware.ts:35) — so it answers 403 CSRF_INVALID to a bearer-authenticated caller that carries no cookie pair (auth.router.ts:1489-1495).
  • Why: Authorization is not CORS-safelisted, so no cross-site page can set it and the exemption costs no CSRF protection: every request it admits is one an attacker could not have forged. The family contract records the reference's behaviour as a MISMATCH that breaks native bearer clients with no cookie jar, and marks it [UNTESTED]. A client that sends the pair is accepted by both.
A password policy on reset-password and change-password

password-policy-on-reset-and-change

  • Surface: POST <prefix>/reset-password and POST <prefix>/change-password.
  • This port: Rejects a new password shorter than Config.MinPasswordLen with 400 {"error": "Password is too weak", "code": "WEAK_PASSWORD"}, checked before the current-password comparison on /change-password.
  • The reference: Applies no strength check on either route — the password goes straight to passwordService.hash. Its own OpenAPI document declares minLength: 8 on both bodies and nothing enforces it (auth.router.ts:801-825, auth.router.ts:904-932).
  • Why: The reference will hash and store a two-character password on a route reached with a mailed token, which silently undoes whatever policy the host applied at registration. WEAK_PASSWORD has no reference counterpart, so a client that does not know the code still sees a 400 it must show the user either way. The check order differs too: the reference would report a wrong current password first.
2fa/setup omits the QR code

totp-setup-omits-qrcode

  • Surface: POST <prefix>/2fa/setup.
  • This port: Answers {"secret", "otpauthUrl"} and nothing else.
  • The reference: Also returns qrCode, the same provisioning URI rendered as a PNG data URL (auth.router.ts:832-835).
  • Why: The root package is stdlib plus golang.org/x/crypto and a QR encoder is neither. A client renders otpauthUrl itself, which is what the Rust port of this family does too. A client that displays the reference's PNG directly has to encode the URI instead.
The default TOTP issuer is Config.Issuer, not awesome-node-auth

totp-issuer-defaults-to-config-issuer

  • Surface: POST <prefix>/2fa/setup, the issuer in otpauthUrl.
  • This port: Labels the provisioning URI with Config.TwoFactorAppName when it is set and with Config.Issuer when it is not — awesome-go-auth on DefaultConfig — as both the label prefix and the issuer parameter, in otplib's issuer:account form.
  • The reference: Labels it with config.twoFactor.appName and falls back to the literal 'awesome-node-auth' — its own package name — when no app name is configured (auth.router.ts:830, totp.strategy.ts:11-17).
  • Why: The reference's fallback names the library, not the deployment, which is the wrong thing to show a user who opens their authenticator app. This port has carried Config.Issuer there since the route existed, so enrolments made before TwoFactorAppName was added sit under that name in users' apps, and switching the default now would file every new enrolment under a different name from the old ones on the same deployment. The difference is visible only on a deployment that sets neither field: one that sets TwoFactorAppName — the reference's appName — gets exactly the reference's label.
  • Matching the reference exactly: WithTwoFactorAppName("awesome-node-auth"). Any name given is carried verbatim; only the fallback differs.
TOTP codes from the adjacent 30-second steps are accepted

totp-accepts-one-step-of-skew

  • Surface: POST <prefix>/2fa/verify-setup and POST <prefix>/2fa/verify, the two routes that check a TOTP code (/2fa/disable takes none, in either implementation).
  • This port: Accepts a code from the previous or the next 30-second step as well as the current one: validateTOTPCode tries TOTPSkew = 1 step either side of now (totp.go), so three codes are valid at any instant and each code is accepted over a 90-second window — its own step and the 30 seconds before and after it — where the reference's window is 30.
  • The reference: Accepts the current step only. TotpStrategy.verify in totp.strategy.ts calls totp.verify(token, { secret }) and sets no other option, and otplib's verify — @otplib/totp 13.4.0, the version the reference's package-lock.json pins for otplib ^13.3.0 (13.4.1 today, same default) — defaults its window to zero seconds either side of now: epochTolerance:f=0 in the verify method of @otplib/totp's dist/index.js, documented in its types as default: 0 = current period only. A code is refused from the first second of the step after its own (totp.strategy.ts:22-25, auth.router.ts:846, auth.router.ts:868).
  • Why: An authenticator runs on the phone's clock, and a user reads a code some seconds before the server sees it, so with no tolerance a code read in the last seconds of a step — or on a phone a few seconds adrift — is refused and the user has to try again, at every step boundary. RFC 6238 §5.2 recommends allowing one step for exactly that delay, and one step is the tolerance mainstream verifiers ship with. The cost is that three of the million possible codes are valid at any instant instead of one.
  • Matching the reference exactly: Not possible: TOTPSkew is a constant, not a knob, and no knob is planned. A deployment gets the one-step window whether or not it wants it.
One-time tokens are base64url, not hex

one-time-tokens-are-base64url

  • Surface: The reset-password, email-verification and email-change tokens carried in mailed links.
  • This port: Renders 32 random bytes as 43 base64url characters.
  • The reference: Renders the same 32 random bytes as 64 hexadecimal characters (token.service.ts:270-272).
  • Why: randomToken is shared with the API-key and IdP code paths, which have no reason to be hex. Both forms are URL-safe and opaque, and no shipped client parses or measures a token — but a host that pinned a column width or a validation regex to 64 hex characters has to widen it.
An advertised 2FA method must be one the store supports

advertised-2fa-methods-require-store-support

  • Surface: POST <prefix>/login, the available2faMethods list in a 2FA challenge.
  • This port: Advertises a second factor only when the configured user store implements the capability that factor needs, as well as the configuration and user state the reference checks.
  • The reference: Checks configuration and user state alone, so it can advertise a method whose route then answers 501 because the store does not implement it (auth.router.ts:557-559).
  • Why: A client picks its next request from this list, so advertising a factor that cannot complete strands the login with no way forward. The extra term can only ever remove an option that would have failed; a deployment whose store implements the capability sees the reference's list.
Config.Require2FA is a deployment-level 2FA policy the reference has no equivalent of

config-require2fa-is-a-system-policy-term

  • Surface: POST <prefix>/2fa/disable and POST <prefix>/login.
  • This port: Config.Require2FA is a third term in two decisions the reference makes with two. On /2fa/disable it is ORed with the stored require2FA into the system-policy refusal, so a deployment that sets it and configures no settings store at all answers 403 {"error":"Cannot disable 2FA: required by system policy","code":"2FA_REQUIRED"} where the reference answers 200 {"success":true} and turns the factor off. At login it is ORed into the challenge term, so a user with no enrolled TOTP and no per-user flag is challenged where the reference logs them straight in.
  • The reference: Has no config-level require2FA: the name exists only on the settings store and on the user record, never on AuthConfig. /2fa/disable refuses on the per-user flag and on the stored setting and on nothing else, and login challenges on an enrolled TOTP or the per-user flag alone (auth.router.ts:880-902, auth.router.ts:552, settings-store.interface.ts:64, user.model.ts:39).
  • Why: A deployment-wide switch is what an embedder without the admin router has instead of the Control panel: the reference reaches the same policy by writing require2FA: true through a settings store, which requires running a store and an admin surface to write it. The term is additive and inert by default — Config.Require2FA is false unless a deployment sets it, and a deployment that leaves it alone gets the reference's answers on both routes, including with a settings store attached. Both wire shapes are the reference's own: the 403 body is the one it sends for the stored setting, and the challenge is the one it sends for the per-user flag, so a client meets nothing it has no branch for.
  • Matching the reference exactly: Leave Config.Require2FA unset (the default) and express the policy through a SettingsStore holding require2FA: true, which is the reference's own term and produces the same /2fa/disable refusal with no login-side difference.

csrf-cookie-not-reissued-with-tokens

  • Surface: Set-Cookie on every route that issues tokens, including POST <prefix>/login and POST <prefix>/refresh.
  • This port: The CSRF cookie is written by the router-level auto-init only, when the request carries no readable one. Issuing tokens does not reissue it, so one response never carries two Set-Cookie headers for that name.
  • The reference: setTokenCookies sets a fresh csrf-token cookie on every cookie-mode issuance — it is reached from sendTokens (auth.router.ts:403) and from the OAuth redirect path — in addition to the router-level auto-init, so a first login emits two Set-Cookie headers for the same name with different values in one response (token.service.ts:204-209, auth.router.ts:529-538).
  • Why: Which of two same-name Set-Cookie headers survives is left to the cookie jar, so the reference's pair makes the token a client will send back ambiguous on exactly the response that establishes it. Emitting one keeps the double-submit pair consistent. The cookie stays JS-readable and valid either way, so a client that reads it per request — as all three family clients do — cannot tell the difference.
OAuth provisioning is a configured policy, with three refusals the reference has no counterpart for

oauth-provisioning-is-a-policy-not-a-function

  • Surface: GET <prefix>/oauth/{provider}/callback.
  • This port: Resolves the callback under OAuthWiring.Provisioning (OAuthProvisioning{AutoCreate, AllowedEmailDomains, RequireVerifiedEmail, OnEmailMatch, FieldMap}). Three of its outcomes are refusals with no reference counterpart, all 403 JSON on the callback: OAUTH_EMAIL_NOT_VERIFIED when RequireVerifiedEmail is set and the provider asserted nothing, OAUTH_EMAIL_DOMAIN_NOT_ALLOWED when the address is outside AllowedEmailDomains, and OAUTH_USER_NOT_PROVISIONED when the identity is unknown and AutoCreate is false, or when the address belongs to an account and OnEmailMatch is reject. The fourth, OnEmailMatch: "conflict", is the reference's own OAUTH_ACCOUNT_CONFLICT: the stash and the 302 to /account-conflict, sent exactly as the reference sends them. An account the callback creates records the provider as loginProvider, takes isEmailVerified from the provider's claim — true when the provider said nothing, which is the common case — and fills further columns from FieldMap.
  • The reference: Has no provisioning at all: findOrCreateUser(profile, state) is an abstract method the integrator implements, and the library knows only two outcomes from it — a user, which becomes a session, or an AuthError coded OAUTH_ACCOUNT_CONFLICT, which becomes the stash and the redirect. Anything else that function throws reaches handleError, which answers 500 unless it is an AuthError carrying its own status (generic-oauth.strategy.ts:169-172, google.strategy.ts:67, github.strategy.ts:78, auth.router.ts:1346-1355).
  • Why: This port's consumer configures the library from a file; it cannot subclass a strategy, so a policy expressed as configuration is the only form the reference's function can take here. The refusals are what that policy needs to say and the reference never had to: its integrator would have thrown whatever they liked. Defaults reproduce what this port did before the policy existed — AutoCreate true, OnEmailMatch link, no domain list, no verification demand — so a deployment that configures nothing cannot see any of the three codes. OnEmailMatch exists because linking by address across providers is the account-takeover shape the reference's own store interface warns about (findByProviderAccount, user-store.interface.ts:105-119), and the port's default is the unsafe one only because changing it silently would lock accounts out of deployments that rely on it.
  • Matching the reference exactly: Leave OAuthWiring.Provisioning nil. The callback then behaves as it always has, no refusal is reachable, and the only policy-driven answer that can appear is the reference's own account conflict — which needs OnEmailMatch: "conflict" and so cannot appear either.

cookie-max-age-follows-configured-ttl

  • Surface: Set-Cookie on every cookie-mode route that issues tokens, including POST <prefix>/login, POST <prefix>/register and POST <prefix>/refresh.
  • This port: Derives each token cookie's Max-Age from the lifetime of the token it carries — the access cookie from Config.AccessTokenTTL, the refresh cookie from Config.RefreshTokenTTL — unless the deployment sets CookieOptions.AccessTokenMaxAge or RefreshTokenMaxAge explicitly. On DefaultConfig that is Max-Age=900 on the access cookie and Max-Age=2592000 (30 days) on the refresh cookie.
  • The reference: Hardcodes both lifetimes in setTokenCookies, ignoring configuration: maxAge: 15 * 60 * 1000 on the access cookie and maxAge: 7 * 24 * 60 * 60 * 1000 on the refresh cookie, so it always emits Max-Age=604800 there whatever refreshTokenExpiresIn says (token.service.ts:28, token.service.ts:195, token.service.ts:199-202).
  • Why: Deriving the cookie lifetime from the configured TTL is the point: a cookie must not outlive, or expire before, the token it carries. The reference's hardcoded value silently contradicts its own refreshTokenExpiresIn — that option signs the refresh token (defaulting to 7d), so raising it to 30 days leaves the token valid for 30 days while the browser drops the cookie carrying it after 7, ending the session early with a credential nobody can present. The divergence is a header value, not a validity change: each port honours its own tokens' server-side expiry either way. The access cookie matches the reference on DefaultConfig (both 15 minutes, Max-Age=900) and diverges on any custom AccessTokenTTL; the refresh cookie diverges at the default too, because Config.RefreshTokenTTL is 30 days here where the reference's refresh token defaults to 7d.
  • Matching the reference exactly: A host that needs the reference's literal headers sets CookieOptions.RefreshTokenMaxAge to 7 days (and AccessTokenMaxAge to 15 minutes); an explicit value is never overwritten by the derivation.
The JWKS CORS wildcard is the one-element []string{"*"}, not an entry

jwks-cors-wildcard-string-form

  • Surface: GET <prefix>/.well-known/jwks.json.
  • This port: A JWKSCORSOrigins of exactly []string{"*"} answers Access-Control-Allow-Origin: * to every request, the same as leaving the field nil. Every other slice is an allowlist: a listed Origin is echoed back, an unlisted one gets no Access-Control-Allow-Origin header at all, and an entry * inside a longer slice is an ordinary entry that matches only an Origin header of literally *.
  • The reference: jwksCorsOrigins is typed string | string[] and the wildcard test is corsOrigins === '*' against the whole value, so only the string is the wildcard. Every array is an allowlist, including ['*'], whose one entry matches only the Origin header * — one no browser sends — so that value disables the header rather than opening the route (auth.router.ts:492-500, auth-config.model.ts:102).
  • Why: Go has no string | string[], and []string is the shape every other list in this package has, so one of the two readings of {"*"} had to win. The reference's own documented default for the field is the wildcard (@default '*'), so the slice that spells it is read as the string form rather than as an allowlist that can never match — the reading a host writing {"*"} plainly intends. The difference is confined to that single value: nil, the empty slice and every other allowlist behave exactly as the reference does.
  • Matching the reference exactly: A deployment that means the reference's ['*'] — an allowlist no browser Origin can match — writes the empty slice []string{} here, which sends the header to nobody. Note also that neither implementation sends Vary: Origin while both send Cache-Control: public, max-age=3600, so an allowlisted response must not reach a shared cache.
Resource-server mode unmounts the whole credential set, not six routes of it

resource-server-gates-all-credential-routes

  • Surface: HTTPConfig.ResourceServer: the nineteen routes of ResourceServerGatedRoutes.
  • This port: Registers none of the nineteen routes that mint, deliver or consume a credential, so each answers 404 — /register, /login, /refresh, /logout, /forgot-password, /reset-password, /change-password, /send-verification-email, /verify-email, /change-email/request, /change-email/confirm, /magic-link/send, /magic-link/verify, /sms/send, /sms/verify, /2fa/setup, /2fa/verify-setup, /2fa/verify and /2fa/disable. GenerateOpenAPISpec drops the same nineteen under OpenAPIInfo.ResourceServer, so the published spec and the mount agree. What stays is /me, the session routes, /profile, /add-phone, /account and the OAuth and linking group — and those still need a local user store: /me reads it through Service.Authenticate, /profile, /add-phone and /account write it, and the OAuth callback provisions a user and mints a local session. The flag is about credentials, not about store independence. The deployment with no user store is the one that mounts ResourceServerMiddleware on its own routes, whose bearer and cookie paths both build the principal from verified claims and read no store at all.
  • The reference: Guards six registrations on isResourceServer — /login, /logout, /refresh, /register, /forgot-password and /reset-password — and leaves the other thirteen mounted. Those thirteen reach handlers that read and write the user store the mode says this instance does not have, so a caller gets a 500 from a failing store lookup, or a 200 on a route that mailed nothing, rather than a routing answer (auth.router.ts:507-510, auth.router.ts:541, auth.router.ts:590, auth.router.ts:622, auth.router.ts:713, auth.router.ts:777, auth.router.ts:802).
  • Why: The reference's own comment for the flag is that this instance has no local user DB and only token verification makes sense, and the six guards are an incomplete application of exactly that rule: a magic link cannot be minted, an SMS code cannot be stored and a TOTP secret cannot be enrolled without the store the mode has removed. Leaving them mounted turns a configuration mistake into a runtime failure on a credential route, which is the worst place to discover it. Gating all nineteen makes the mode mean one thing, and makes it checkable: the same list drives the mount, the OpenAPI spec and the conformance suite. It is drawn at the credential and not at the store deliberately: gating every store-reading route would unmount /me and the account routes from the deployment that has both a store and a remote issuer, which is the commoner configuration, to protect one that has no reason to mount the auth router at all.
  • Matching the reference exactly: Leave HTTPConfig.ResourceServer unset and mount ResourceServerMiddleware on the host's own routes: the bearer verification is independent of the gating, and every auth route then stays mounted as it is today. A host that wants the reference's partial set mounts two routers — one with the flag, one without — under different prefixes.
An unknown kid refetches the JWKS at most once per MinRefreshInterval

jwks-unknown-kid-refetch-is-rate-limited

  • Surface: VerifyRS256 / JWKSClient: the rotation retry behind every bearer token.
  • This port: On a kid the cached JWKS does not carry, the cache is invalidated and the key looked up once more — but only when the cached document is older than ResourceServerConfig.MinRefreshInterval (default DefaultJWKSMinRefreshInterval, 30 seconds). Inside that window the token is refused with Unknown signing key and no HTTP call is made, so N requests bearing unknown kids cost at most one outbound fetch per interval. A negative MinRefreshInterval turns the limit off and restores the reference behaviour exactly. The RS256 allow-list is also checked before the key lookup rather than after it, so an alg: none or alg: HS256 token never reaches the issuer at all; both orderings answer 401 INVALID_TOKEN, only the logged message differs.
  • The reference: Calls jwksClient.invalidateCache() and retries on every unknown kid, with no interval and no cap. invalidateCache clears the cached document and the in-flight fetchPromise, so concurrent requests do not even coalesce onto one fetch, and the algorithms: ['RS256'] pin is inside jwt.verify, which runs after the key lookup (token.service.ts:116-125, token.service.ts:136, jwks.service.ts:101-105, jwks.service.ts:56-58).
  • Why: Unrestricted, the retry is an unauthenticated request amplifier: anyone who can reach the resource server makes it call the issuer once per request by sending a random kid, and because the in-flight handle is dropped too, a burst multiplies rather than coalesces. Worse, the refusal is collateral: every legitimate request arriving between the invalidation and the next document landing takes the cold path and blocks on the issuer, which is the one thing the stale-while-revalidate cache exists to prevent. The interval costs a real rotation nothing — a cached document is typically an hour old by the time a token names a key it does not carry, so the first such token still refetches and still verifies — and only refuses the case of two rotations inside 30 seconds. node-jwks-rsa ships the same guard as its rateLimit option for the same reason.
  • Matching the reference exactly: Set ResourceServerConfig.MinRefreshInterval to any negative duration: every unknown kid then invalidates and refetches, as the reference does. Nothing else in the verifier changes.
register opens a session, where the reference only creates the account — provisional, tracked as nik2208/awesome-go-auth#21

register-issues-a-session

  • Surface: POST <prefix>/register.
  • This port: Mints a token pair for the new account and delivers it with the 201, through the same delivery switch every other issuing route uses: in cookie mode the response is 201 {"success": true, "userId": "…"} plus Set-Cookie for accessToken and refreshToken, and in bearer mode (X-Auth-Strategy: bearer) the same body with top-level accessToken and refreshToken fields and no cookies at all. A refresh session row is created with it, so the account is logged in as soon as it exists and GET <prefix>/me answers on the credential the registration returned.
  • The reference: Mounts the route at all only when the host supplies options.onRegister; without it there is no POST <prefix>/register in the reference and the path answers 404 where this port answers 201 (or 400 INVALID_INPUT for a body missing a credential). That unconditional mount is a second, smaller difference on this surface, and it is named here rather than kept as a separate entry because a host that has no register route has no session question to ask. Where the reference is mounted it answers 201 {"success": true, "userId": user.id} and nothing else: the register route never reaches sendTokens — the one function that writes setTokenCookies or the body tokens — and never reaches issueTokens, so no cookie is set, no token is returned and no session row is created. The caller is unauthenticated after a successful registration and has to POST /login with the credentials it just chose (auth.router.ts:713-730, auth.router.ts:726, auth.router.ts:399-406).
  • Why: Provisional, and recorded rather than endorsed. The entry exists so that a difference which is client-visible today is visible in the contract too; it is not a settled product decision. What it costs is a bypass of the email verification gate: Service.Register sets IsEmailVerified from the configured mode and then mints the token pair unconditionally, so under strict a brand-new account walks away holding a usable access token that POST <prefix>/login would have refused for the same user with 403 EMAIL_NOT_VERIFIED. The registration hands out exactly the credential the gate exists to withhold. The family already carries that as a defect and not as a decision: it is tracked as nik2208/awesome-go-auth#21, and awesome-lambda-auth's contract suite pins the current behaviour under protest in test/contract/cases_register_test.go, which calls it security-relevant and is written to fail the moment #21 lands. This entry follows that suite: when #21 lands, the behaviour changes and the entry is retired, not reworded. What has kept the issuance in place so far is only the first-run cost of the alternative — a registration that leaves the caller logged out makes the first thing a new account does re-present the password it typed one screen earlier, and issuing here removes that round trip. That argument covers the round trip and nothing else; it is not a reason to skip a verification gate the deployment asked for, and where the two conflict the gate is the stronger claim. The difference has stayed invisible this long because it costs the shipped clients nothing: ng-awesome-node-auth posts the registration withCredentials and reads only userId off the body, so the cookies simply land in the jar and its next session check succeeds instead of redirecting to the login form; the Flutter client reads userId (or id) and ignores every other field, so on native it discards the tokens and logs in exactly as it does today, and on web it inherits the same cookie jar. Neither reads a field this port omits, and neither has a branch that a present session breaks — but a client that does not notice is not a client that consented, and it is the gate, not the client, that #21 is about.
  • Matching the reference exactly: Not possible today: there is no knob. The issuance is unconditional in the handler and no configuration field switches it off, which is part of why nik2208/awesome-go-auth#21 is open rather than closed as configurable: a deployment running EmailVerificationModeStrict cannot opt out of the email verification bypass described above. The only way to get the reference's answer now is to not mount POST <prefix>/register — HTTPConfig.ResourceServer unmounts it along with the rest of the credential set — and create accounts through UserStore itself, verifying the address before the first login. Read "no knob" as the state of this release and not as a decision that it stays that way: the fix for #21 is expected to remove the issuance rather than add a switch.
ui/config: features.verifyEmail follows the effective verification mode

ui-config-verify-email-follows-the-effective-mode

  • Surface: GET <prefix>/ui/config, the features.verifyEmail flag.
  • This port: Answers true when Config.SendEmailVerification is wired and the effective Config.EmailVerificationMode is lazy or strict. An unset mode is none here as it is everywhere else in this port, and DefaultConfig writes none into the field outright, so a deployment that wired the sender and left the mode alone is told false and its login page offers no verify-email affordance.
  • The reference: Asks (sendVerificationEmail || mailer) && (emailVerificationMode !== 'none' || requireEmailVerification). An unset mode passes the second term, since undefined !== 'none' is true in JavaScript, so the same deployment is told true — even though the reference's own fallback makes an unset mode behave as 'none' in every decision that acts on it. Its deprecated requireEmailVerification boolean is a third route to true, reaching it even with the mode set to 'none'; this port has no field of that name (ui.router.ts:121, auth-config.model.ts:286-298).
  • Why: The family's clients read features to decide which affordances to render, so a flag that differs is a difference a consumer sees: a node-auth deployment that wired a verification sender and never set the mode loses the verify-email affordance when it moves here. Reproducing the term costs more than it buys. It needs a distinction between an unset EmailVerificationMode and one set to none that nothing else in this port makes — the service normalises the empty string to none for registration, for login and for the 2FA path — and DefaultConfig erases that distinction anyway, so the flag would answer differently for two configurations that behave identically on every route, according to which of the two the host happened to build. It would also advertise a step the deployment does not perform: with the effective mode none, Register marks the address verified on the spot and verification never comes up. Answering for what this deployment does is the reading closest to what the flag means.
  • Matching the reference exactly: Set Config.EmailVerificationMode to lazy or strict — which is what a deployment that wires a verification sender generally means — and the flag answers true, as the reference does for the same deployment. There is no way to reach true with the mode at none, because the legacy boolean that reaches it there does not exist here.
POST <prefix>/register is unconditional, and ui/config reports it as such

register-route-is-always-mounted

  • Surface: POST <prefix>/register, and the features.register flag of GET <prefix>/ui/config.
  • This port: Registration is part of the library rather than a hook the host supplies: every adapter mounts POST <prefix>/register on Service.Register on every deployment, and GET <prefix>/ui/config answers features.register: true to match. Resource-server mode is the one thing that unmounts the route, with the rest of the credential set — see resource-server-gates-all-credential-routes — and the flag does not follow it there, which is what the reference does too for a resource server that supplied the hook.
  • The reference: Mounts /register only when routerOptions.onRegister is supplied, the host having written the account-creation function itself, and reports !!routerOptions?.onRegister in features.register. A deployment that supplies no hook answers 404 on the route and tells the UI to hide the sign-up affordance (auth.router.ts:712-715, ui.router.ts:115).
  • Why: This port ships registration instead of asking for it: Service.Register applies the password policy, hashes the password, applies the email verification mode and writes through UserStore. There is no hook that can be absent, so there is nothing for the route's presence to be conditional on, and the flag — derived from the wiring rather than configured — answers for the routes this port actually serves. The difference runs one way only: a client is offered sign-up against a deployment whose node-auth counterpart, having no onRegister, would have hidden it.
  • Matching the reference exactly: Not available from configuration. A host that wants no public sign-up mounts the adapter on its own mux and refuses POST <prefix>/register there, or runs in resource-server mode; features.register still answers true in the first case, so such a host hides the affordance in its own UI rather than reading the flag.
The documentation routes are off until asked for, and the document describes them

docs-routes-are-opt-in

  • Surface: HTTPConfig.Docs.Enabled: GET <prefix>/openapi.json and GET <prefix>/docs.
  • This port: Registers neither route unless HTTPConfig.Docs.Enabled is set, so a deployment that configures nothing answers 404 for both. Set, the two answer what the reference answers, to a request carrying no credential of any kind: the generated document as application/json, and the reference's Swagger UI page reproduced byte for byte, swagger-ui-dist@5 from the unpkg CDN included. Neither route has an auth gate, as neither has one there; both sit behind the CSRF middleware, as every reference route registered after the router-level auto-init does, which on a GET only writes the csrf-token cookie to a reader who arrives without one. The served document additionally describes those same two paths, which is what OpenAPIInfo.Docs adds; the reference's generator describes neither, under any option.
  • The reference: Registers both when swagger === true || (swagger !== false && NODE_ENV !== 'production'). The option defaults to 'auto', which is the second arm, so a deployment that configures nothing serves them everywhere except where the process environment sets NODE_ENV to exactly production. Its generator emits the auth routes and stops, so neither /openapi.json nor /docs appears in the document it serves (auth.router.ts:123-131, auth.router.ts:529-538, auth.router.ts:1652-1654, auth.router.ts:1656-1677, openapi.ts:1646-1669).
  • Why: What a library serves must follow from its own configuration, not from a process-wide variable it never sees set. NODE_ENV is a Node convention with no Go counterpart — there is no one variable a Go deployment agrees on, and picking one would make these routes appear and disappear on a value the caller never passed to this library. Reading the ambient environment is the host's call, and Docs.Enabled is where its answer goes, which is also the reference's own swagger: true | false for a host that wants to decide rather than infer. Defaulting to off rather than to on is the safe direction of that choice: an unserved document is a missing convenience, a served one is a description of the surface an attacker would otherwise have to guess — and <prefix>/docs is more than a description. The page is the reference's, so it loads swagger-ui-dist@5 from the unpkg CDN with no subresource integrity, and whatever that CDN serves then runs on the auth origin, where the csrf-token cookie is readable from JavaScript by design. A deployment should keep the UI route off in production, or serve it behind a Content-Security-Policy that pins the CDN.
  • Restoring the reference's default: One line where the HTTPConfig is built — cfg.Docs.Enabled = os.Getenv("APP_ENV") != "production" — with whatever variable the deployment actually uses. Nothing else changes: the routes, the bodies and the mount are the same either way.
  • Why the document lists itself: The wire conformance suite compares the generated document to the mounted routes in both directions, on every adapter: a documented operation that answers 404 fails, and so does a mounted route the document omits. A document that hid the endpoint serving it would have to be exempted from the second half, and the exemption is what lets a spec drift. OpenAPIInfo.Docs is set with HTTPConfig.Docs.Enabled and describes exactly the two paths that flag mounts.

Parity Snapshot vs awesome-node-auth

Status as of v0.3.1, verified against the code rather than the intent. ✅ means the surface is mounted by all four adapters and covered by the wiretest conformance suite; ⚠️ means the building blocks exist but a caller cannot get the reference's behaviour without writing code; ❌ means absent. The milestone column names the release that closes the gap.

Capability Status in awesome-go-auth Notes Closes in
Auth strategies (email/password, magic link, SMS OTP, TOTP 2FA) ✅ Implemented Every route mounted on net/http, chi, gin and echo; delivery through Config.Send* senders. —
Token management (cookie/bearer, access/refresh rotation, secure cookies) ✅ Implemented HS256 JWS, X-Auth-Strategy: bearer, rotation on refresh, __Host-/__Secure-/bare cookie policy. —
Stateful sessions ✅ Implemented Revocation, rotation, Config.SessionCheckOn (allcalls/refresh/none). —
CSRF protection ✅ Implemented CSRFMiddleware, double-submit cookie + header, exemption table pinned to the reference. —
Account management ✅ Implemented Register, UpdateProfile, DeleteAccount, password and email lifecycle. —
OAuth login + account linking ✅ Implemented Signed state, PKCE, single-use nonce; Google and GitHub presets, AdditionalAuthParams and declarative ProfileMap/MapProfile for generic providers; OAuthProvisioning replaces the reference's abstract findOrCreateUser (auto-create, domain allowlist, verified-address demand, FieldMap), and the account-conflict flow is complete — stash, the reference's /account-conflict redirect, then /link-request and /link-verify. —
Dynamic email templates + UI i18n fallback ✅ Implemented The reference's six template ids with its en/it built-ins, TemplateStore overrides rendered under its {{T.key}}/{{key}} rule, per-request site-URL links and the old-address notice on /change-email/confirm. The welcome template renders but POST /register does not mail it yet (the reference does, auth.router.ts:719-724); UI translations are stored and are read by GET <prefix>/ui/config, which serves the config page for the requested language with the reference's en fallback. —
Custom token claims ✅ Implemented Config.BuildTokenClaims hook, plus StaticClaims/UserFieldClaims/ChainClaims and the synchronous ClaimsWebhook (this port's extension); the hook runs at mint time and on /me, never in the middleware. —
Identity Provider (IdP) mode (RS256 + JWKS + resource-server validation) ✅ Implemented Not yet an enforcing authorization server: PKCE parameters are recorded with the authorization code and never checked at the token endpoint, and which token pair token returns is an open design decision (upstream plan D-15). The reference ships no OIDC authorization server, so those four endpoints are this port's own surface rather than a parity item; what the ✅ claims is the row's legend — mounted on all four adapters and covered by the wire conformance suite. Discovery, authorize, token and userinfo are mounted by all four adapters; the signing key, kid and published keys are injectable (IDPConfig.Signer, KeyID, PublicKeys, with ParseRSAPrivateKeyPEM for the reference's PEM form), authorization codes go through AuthCodeStore, and IssueIdPTokenPair mints the reference's RS256 pair. Both halves of the JWKS contract are in: auth.WithIDP makes all four adapters serve the document at <prefix>/.well-known/jwks.json (IDPConfig.JWKSPath) with the reference's Cache-Control and CORS headers, with <base>/jwks kept as a deprecated alias through the 0.x line and removed in v1.0.0; and on the consuming side JWKSClient caches a remote JWKS with stale-while-revalidate, VerifyRS256 verifies a bearer token against it (RS256 pinned before the key lookup, kid rotation retried once and rate-limited, iss checked), ResourceServerMiddleware is wired on all four adapters — bearer against the JWKS, cookie against the local HS256 secret, neither path reading a store — and HTTPConfig.ResourceServer unmounts the credential routes. The four OIDC endpoints are mounted from that same auth.WithIDP switch at <prefix>/.well-known/openid-configuration, <prefix>/authorize, <prefix>/token and <prefix>/userinfo, every method reaching the handler as (*IDP).RegisterHandlers has always mounted them, and the wiretest suite covers all four on all four adapters; HTTPConfig.ResourceServer unmounts <prefix>/authorize and <prefix>/token along with the other credential routes and leaves discovery, userinfo and the JWKS document public. RegisterHandlers stays for a host that would rather serve them on a mux of its own; doing both at once puts the same endpoints at two URLs, and on a single http.ServeMux that is a mount-time panic rather than a split endpoint — on a chi, gin or echo host the adapter and the RegisterHandlers mux are different routers, so there is no panic and the endpoints simply end up served twice. —
RBAC ⚠️ Service-level RolesPermissionsStore and service helpers; no HTTP surface (the admin router is absent). v0.10.0
Multi-tenancy ⚠️ Service-level TenantStore and membership helpers; no HTTP surface. v0.10.0
API keys (M2M) ⚠️ Service-level APIKeyService + APIKeyMiddleware; no management routes. v0.10.0
Admin panel ❌ Absent ServeAdminUI() serves a static page; none of the reference's admin routes exist, and no admin guard. v0.10.0
Built-in UI + auth runtime (auth.js) ⚠️ Partial ServeAuthUI()/ServeAuthJS() serve hand-written assets, not the reference's. GET <prefix>/ui/config is mounted on all four adapters under HTTPConfig.UI.Enabled and serves the reference's document — feature flags derived from the wiring, branding from UIOptions.Branding with the SettingsStore on top, translations from the template store's config page, the echoed headless flag and the reduced store-failure fallback. What keeps this ⚠️: the HTML pages, the static assets and the uploaded logos are not served yet. v0.8.0
OpenAPI / Swagger docs ✅ Implemented HTTPConfig.Docs.Enabled makes all four adapters serve GET <prefix>/openapi.json and GET <prefix>/docs, with no auth guard of their own as the reference registers them and behind the CSRF middleware as its router-level auto-init puts every route registered after it — which on a GET only hands the csrf-token cookie to a reader who arrives without one; the page is the reference's, swagger-ui-dist@5 from the unpkg CDN and all, and DocsOptions.BasePath is its swaggerBasePath — it moves the description, never the mount. Off by default, where the reference's 'auto' reads NODE_ENV: that call is the host's here (docs-routes-are-opt-in). —
Event-driven tooling (event bus, SSE, inbound/outbound webhooks, telemetry, notify) ⚠️ Primitives only EventBus, SseHub, WebhookDispatcher and TelemetryStore exist, but the service publishes no events, there is no tools router, and the outbound webhook headers differ from the reference. v0.11.0
Client libraries compatibility (Angular + Flutter) ✅ For the auth surface Verified by awesome-lambda-auth with both official clients unmodified against a live stack. —
Rate limiting ✅ Slot implemented The reference ships no algorithm — it declares a slot (RouterOptions.rateLimiter) and spreads it onto every auth route. HTTPConfig.RateLimiter is that slot: a func(http.Handler) http.Handler applied by all four adapters to every auth route, outside the CSRF and auth middlewares as the reference applies it, nil (the default) meaning none. The algorithm stays the integrator's middleware, as it is there. —
MCP server (awesome-node-auth-mcp-server) ➖ Out of scope Out of parity scope for this library. —
Roadmap

The gaps above close in order, one minor release per milestone. Shipped so far: v0.4.0 email flows (site URLs, template store, delivery webhook), v0.5.0 2FA knobs, token claims and the IdP signing key, v0.6.0 OAuth provisioning, the JWKS route, RS256 verification and the settings store.

What is left, and where each row of the table above closes:

Release Closes
v0.7.0 served OpenAPI and Swagger UI, GET /ui/config, a rate-limiter middleware slot, the OIDC endpoints mounted by the adapters, a password-verifier seam
v0.8.0 the store seams the admin surface needs: user, session and role listers, a complete APIKeyStore, a WebhookStore
v0.9.0 the reference's UI assets vendored byte for byte, with SSR and the page catch-all
v0.10.0 the admin router, all fifty-one routes
v0.11.0 the event plane, the reference's outbound-webhook wire format, the SseManager protocol and the tools router
v1.0.0 removal of the shims those releases deprecate, and a final documentation truth pass

v0.11.0 carries the breaking removals (SseHub, WebhookDispatcher, the X-Signature-SHA256 header, the old TelemetryEvent shape), which is why they are gathered into one release rather than spread across three.

Documentation

Index

Constants

View Source
const (
	SessionCheckOnAllCalls = "allcalls"
	SessionCheckOnRefresh  = "refresh"
	SessionCheckOnNone     = "none"
)
View Source
const (
	EmailVerificationModeNone   = "none"
	EmailVerificationModeLazy   = "lazy"
	EmailVerificationModeStrict = "strict"
)

Email verification modes accepted by Config.EmailVerificationMode.

none   : Register marks the address verified, so verification never comes up
         on the self-registration path. An address left unverified by some
         other route (admin provisioning, a data import, a custom UserStore
         whose column defaults to false) is still refused at login. This is
         the default and is exactly how the service behaved before the mode
         existed.
lazy   : Register leaves the address unverified and login is allowed anyway.
         This is the only mode in which an unverified address may log in.
strict : Register leaves the address unverified and login is refused until
         the address is confirmed.
View Source
const (
	PasswordResetPath      = "/reset-password"
	EmailVerificationPath  = "/verify-email"
	EmailChangeConfirmPath = "/change-email/confirm"
)

The routes the three tokens are spent on, relative to the mount prefix.

View Source
const (
	DeliveryKindMagicLink         = "magic-link"
	DeliveryKindPasswordReset     = "password-reset"
	DeliveryKindEmailVerification = "email-verification"
	DeliveryKindEmailChange       = "email-change"
	DeliveryKindSMSCode           = "sms-code"
)

Delivery kinds: the value of the request's "kind" field and, prefixed with "delivery.", of the X-Webhook-Event header.

View Source
const (
	// DocsSpecPath and DocsUIPath are where the two routes sit below the mount
	// prefix (auth.router.ts:1658, :1674). They are constants and not
	// configuration: the reference has no option for either, and the family's
	// clients that read a spec read it from the first.
	DocsSpecPath = "/openapi.json"
	DocsUIPath   = "/docs"
)
View Source
const (
	OIDCDiscoveryPath = "/.well-known/openid-configuration"
	OIDCAuthorizePath = "/authorize"
	OIDCTokenPath     = "/token"
	OIDCUserInfoPath  = "/userinfo"
)

The four OIDC endpoints, below the mount prefix. They are constants because four places have to spell them identically: RegisterHandlers, the four adapters' MountWithConfig, the JWKSPath collision check, and the generated OpenAPI document.

Unlike the JWKS route these paths are not configurable. They have no counterpart in the reference, which ships no OIDC authorization server, so there is nothing to track: the discovery document is the well-known path RFC 8414 fixes, and the other three are the names the discovery document this package has always served advertises (handleDiscovery below derives them from Issuer, so a second spelling would advertise one path and serve another).

View Source
const (
	TwoFactorMethodTOTP      = "totp"
	TwoFactorMethodSMS       = "sms"
	TwoFactorMethodMagicLink = "magic-link"
)

Second-factor method names, exactly as the reference spells them in available2faMethods (auth.router.ts:557-559). A client picks its next request from this list, so these strings are wire: "magic-link" is hyphenated and nothing is capitalised.

View Source
const (
	TemplatePasswordReset = "password-reset"
	TemplateMagicLink     = "magic-link"
	TemplateWelcome       = "welcome"
	TemplateVerifyEmail   = "verify-email"
	TemplateEmailChanged  = "email-changed"
	TemplateInvitation    = "invitation"
)

The six template ids the reference renders, as its MailerService names them (mailer.service.ts:186, 191, 196, 201, 206, 211). They are the ids a TemplateStore is asked for and the ids Register, RenderMail and Render take.

View Source
const (
	// OAuthEmailMatchLink links the provider account to the account that
	// already holds the address and signs that account in. It is the default
	// and what this port did before the policy existed.
	OAuthEmailMatchLink = "link"
	// OAuthEmailMatchConflict refuses to link and raises the reference's
	// OAUTH_ACCOUNT_CONFLICT: the callback stashes (email, provider,
	// providerAccountId) and redirects the browser to /account-conflict, where
	// the front-end drives /link-request and /link-verify so the link is made
	// only after the address has been proven by mail.
	OAuthEmailMatchConflict = "conflict"
	// OAuthEmailMatchReject refuses outright, with no linking flow offered:
	// 403 OAUTH_USER_NOT_PROVISIONED, the same answer AutoCreate false gives,
	// because both mean the deployment will not provision this identity.
	OAuthEmailMatchReject = "reject"
)

The three OnEmailMatch modes.

View Source
const (
	CodeOAuthStateInvalid      = "OAUTH_STATE_INVALID"
	CodeOAuthTokenExchangeFail = "OAUTH_TOKEN_EXCHANGE_FAILED"
	CodeOAuthProfileFailed     = "OAUTH_PROFILE_FAILED"
	CodeEmailRequired          = "EMAIL_REQUIRED"
	CodeTokenRequired          = "TOKEN_REQUIRED"
	CodeInvalidLinkToken       = "INVALID_LINK_TOKEN"
	CodeLinkTokenExpired       = "LINK_TOKEN_EXPIRED"
	CodeUnauthorized           = "UNAUTHORIZED"

	// CodeOAuthAccountConflict is the reference's own literal, and the only one
	// of these four that reaches a client the reference also sends it to: it
	// travels in the query of the /account-conflict redirect rather than in an
	// error body (auth.router.ts:1346-1355).
	CodeOAuthAccountConflict = "OAUTH_ACCOUNT_CONFLICT"

	// The three provisioning refusals. They have no reference counterpart —
	// the reference has no provisioning at all — and are registered in
	// compatibility.go; none of them can be emitted by a deployment that
	// configures no OAuthProvisioning.
	CodeOAuthEmailNotVerified      = "OAUTH_EMAIL_NOT_VERIFIED"
	CodeOAuthEmailDomainNotAllowed = "OAUTH_EMAIL_DOMAIN_NOT_ALLOWED"
	CodeOAuthUserNotProvisioned    = "OAUTH_USER_NOT_PROVISIONED"
)

Error codes for this route group. The first five are the reference's own literals; OAUTH_STATE_INVALID has no counterpart because the reference never rejects a state.

View Source
const (
	CodeTempTokenRequired = "TEMP_TOKEN_REQUIRED"
	CodeInvalidTempToken  = "INVALID_TEMP_TOKEN"
	// CodeInvalidAccessToken is what /2fa/verify answers for a bad step-up
	// token where its siblings answer INVALID_TEMP_TOKEN. See
	// HTTPErrInvalidStepUpToken.
	CodeInvalidAccessToken = "INVALID_ACCESS_TOKEN"
	CodeInvalidMagicLink   = "INVALID_MAGIC_LINK"
	CodeTokenMismatch      = "TOKEN_MISMATCH"
	CodePhoneNotSet        = "PHONE_NOT_SET"
	// CodeEmailNotConfigured and CodeSMSNotConfigured report a deployment that
	// cannot deliver what the send route just minted. See delivery.go.
	CodeEmailNotConfigured = "EMAIL_NOT_CONFIGURED"
	CodeSMSNotConfigured   = "SMS_NOT_CONFIGURED"
)

Error codes for this section of the contract.

View Source
const (
	DefaultJWKSCacheTTL     = time.Hour
	DefaultJWKSFetchTimeout = 5 * time.Second
)

Defaults for ResourceServerConfig, from the reference JwksClient constructor (jwks.service.ts:40-41): cacheTtl 3_600_000 ms and fetchTimeout 5000 ms.

View Source
const (
	// TOTPAlgorithm is the HMAC hash, spelled as the otpauth:// `algorithm`
	// parameter spells it. generateTOTPCode's hmac.New(sha1.New, …) is the
	// implementation of this value.
	TOTPAlgorithm = "SHA1"
	// TOTPDigits is the length of a code.
	TOTPDigits = 6
	// TOTPPeriod is the length of one time step.
	TOTPPeriod = 30 * time.Second
	// TOTPSkew is the number of steps either side of now a code is accepted for.
	TOTPSkew = 1
)

The TOTP parameters. They are stated once, here, and read by both the provisioning URI (totpProvisioningURI) and the verifier (validateTOTPCode), so that what an authenticator app is told at enrolment cannot drift from what the server later checks a code against.

TOTPAlgorithm, TOTPDigits and TOTPPeriod are RFC 6238's defaults and the ones the reference runs with: it constructs otplib's TOTP with nothing but the crypto and base32 plugins (totp.strategy.ts:5-8), so `totp.verify` (:23) takes the library's own algorithm "sha1", digits 6 and period 30 — not knobs there (auth-config.model.ts:272-274 has only `twoFactor.appName`), and not knobs here. They are exported so that a host rendering its own URI, or a test generating codes, reads the same values rather than restating them.

TOTPSkew is the number of whole steps either side of the current one a code is accepted for, and is this port's tolerance, not the reference's: otplib's `epochTolerance` option defaults to 0 seconds (@otplib/totp 13.4.0, the version the reference's package-lock.json pins; the reference pins otplib ^13.3.0 and never sets it), so the reference accepts the current step only. One step of skew is the allowance RFC 6238 §5.2 recommends for clock drift and network delay.

View Source
const (
	UIDefaultPrimaryColor   = "#4a90d9"
	UIDefaultSecondaryColor = "#6c757d"
	UIDefaultSiteName       = "Awesome Node Auth"
	// UIDefaultLang is the language the document falls back to when the request
	// asks for none and none is configured, and the language whose translations
	// stand in for a language the template store does not hold
	// (ui.router.ts:103, :110).
	UIDefaultLang = "en"
)

The branding defaults. They are the reference's literals (ui.router.ts:126-129, repeated in its error fallback at :149-151) and are wire values rather than a claim about who wrote the server: the family's clients render siteName as the page heading, so a port that substituted a default of its own would show a different login page for the same configuration.

View Source
const (
	// AuthStrategyHeader opts a caller into bearer delivery. The reference reads
	// this header in exactly one place and compares the value exactly, so the
	// match here is case-sensitive on purpose.
	AuthStrategyHeader = "X-Auth-Strategy"
	// AuthStrategyBearer is the only recognised AuthStrategyHeader value.
	AuthStrategyBearer = "bearer"
	// CSRFHeaderName carries the double-submit token.
	CSRFHeaderName = "X-CSRF-Token"
)
View Source
const (
	AccessTokenCookieName  = "accessToken"
	RefreshTokenCookieName = "refreshToken"
	CSRFTokenCookieName    = "csrf-token"
)

Cookie base names. The prefix a cookie actually carries is resolved by CookieOptions.CookieName.

View Source
const (
	CodeInvalidCredentials  = "INVALID_CREDENTIALS"
	CodeEmailNotVerified    = "EMAIL_NOT_VERIFIED"
	CodeSessionRevoked      = "SESSION_REVOKED"
	CodeInvalidToken        = "INVALID_TOKEN"
	CodeInvalidRefreshToken = "INVALID_REFRESH_TOKEN"
	CodeCSRFInvalid         = "CSRF_INVALID"
	CodeTwoFactorRequired   = "2FA_REQUIRED"
	CodeUserNotFound        = "USER_NOT_FOUND"
	CodeNotImplemented      = "NOT_IMPLEMENTED"
)

Error codes emitted by the HTTP layer. The first block is the reference catalog; a client keying on any of these keeps working across the family.

View Source
const (
	CodeUserExists   = "USER_EXISTS"
	CodeWeakPassword = "WEAK_PASSWORD"
	CodeInvalidBody  = "INVALID_BODY"
	CodeInvalidInput = "INVALID_INPUT"
)

Codes with no counterpart in the published reference catalog. The reference delegates registration to a host-supplied callback and therefore has no code of its own for a duplicate address or a rejected password; these fill that hole. No shipped client branches on them.

CodeInvalidInput is the one with a counterpart, but not in the published reference: it comes from the private dev line node-auth (DevLineRevision), which mounts POST /register by default and gives that default handler a presence check on the two fields, refusing the pair with 400 {"error": "Email and password are required", "code": "INVALID_INPUT"} before it hashes or stores anything (node-auth auth.router.ts:515-525). The message and the code here are that handler's, spelled exactly, so a client written against either sees one answer.

The published reference at ReferenceRevision has neither: it mounts /register only when the host supplies options.onRegister (auth.router.ts:713) and has no default handler, so the string INVALID_INPUT does not occur in it. Copying an answer from an unreleased tree is a deliberate bet, recorded in DevLineRevision's own doc comment; nothing else in this package does it.

View Source
const ClaimsWebhookEvent = "claims.build"

ClaimsWebhookEvent is the X-Webhook-Event value of every request a ClaimsWebhook sends.

View Source
const CodePasswordRequired = "PASSWORD_REQUIRED"

CodePasswordRequired is the one coded error in this group. Every other failure here is code-less in the reference, and a client is documented not to pattern-match on an absent code.

View Source
const CodeTwoFactorSetupRequired = "2FA_SETUP_REQUIRED"

CodeTwoFactorSetupRequired tells a client that a second factor is demanded of this account and it has none to present, so enrolment has to come first. It is declared here rather than in wire.go's catalog because the body it appears in is not the error envelope — see WriteTwoFactorChallenge — and because passwordless.go sets the precedent of a feature file owning its own codes.

View Source
const DefaultAPIPrefix = "/auth"

DefaultAPIPrefix is where the auth routes mount unless told otherwise.

View Source
const DefaultIDPKeyID = "provisioner-key-1"

DefaultIDPKeyID is the kid the IdP signs with and publishes when IDPConfig.KeyID is empty: the constant the reference hardcodes for every IdP-issued token and for the JWKS document (token.service.ts:78, jwks.service.ts:184).

View Source
const DefaultJWKSMinRefreshInterval = 30 * time.Second

DefaultJWKSMinRefreshInterval is how long a freshly fetched document is trusted to be complete: inside it, a token naming a kid the document does not carry is refused without a refetch.

It has no counterpart in the reference, which invalidates and refetches on every unknown kid (token.service.ts:116-125). That is an unauthenticated request amplifier — anyone who can reach the resource server can make it call the issuer once per request by sending a random kid, and because Invalidate drops the in-flight handle too, concurrent junk does not even coalesce. See the jwks-unknown-kid-refetch-is-rate-limited deviation.

Thirty seconds is picked the way node-jwks-rsa picks its rateLimit default: long enough that a flood costs one fetch, short enough that a real rotation is picked up in the same breath as it happens.

View Source
const DefaultJWKSPath = "/.well-known/jwks.json"

DefaultJWKSPath is where the adapters serve the JWKS document, below the mount prefix, when IDPConfig.JWKSPath is empty: the reference's `idProvider.jwksPath ?? '/.well-known/jwks.json'` (auth.router.ts:475).

View Source
const DevLineRevision = "nik2208/node-auth@e8af923 (unreleased, package.json 1.9.0)"

DevLineRevision names the second tree this package cites: nik2208/node-auth, the private line the published reference is cut from.

Nothing in the deviation register resolves against it. Deviation.Citations is ReferenceRevision only, and compatibility_test.go enforces that. This constant exists because a few wire facts outside the register do come from the dev line, and a reader has to be able to tell which tree a file:line in a doc comment means: a citation written as "node-auth auth.router.ts:N" is this revision, a bare "auth.router.ts:N" is ReferenceRevision.

One such fact exists today, and it is deliberate rather than accidental: the INVALID_INPUT refusal on POST /register (CodeInvalidInput in wire.go, ErrInvalidInput in errors.go, the check in Service.Register). The dev line mounts that route by default and gives its default handler a presence check on the two fields; the published reference mounts /register only when the host supplies options.onRegister (auth.router.ts:713), has no default handler at all, and therefore has no message and no code of its own there. Copying the dev line's answer is a bet that the published tree will grow the same one. When the dev line ships, ReferenceRevision moves forward and those citations fold back into it.

View Source
const LoginProviderLocal = "local"

LoginProviderLocal is the loginProvider a user without a recorded provider is reported under, on tokens and on the profile alike: the reference's `user.loginProvider ?? 'local'` (auth.router.ts:379).

View Source
const MagicLinkVerifyPath = "/magic-link/verify"

MagicLinkVerifyPath is the route a magic link points at, relative to the mount prefix.

View Source
const ReferenceRevision = "awesome-node-auth@cc01e997 (npm 1.9.0)"

ReferenceRevision names the revision every Deviation.Citations entry resolves against. The citations are file:line, so they mean nothing without it.

View Source
const StepUpMode = "2fa"

StepUpMode is the only value of the `mode` field the routes special-case.

View Source
const UIConfigRoute = "/ui/config"

UIConfigRoute is where the document is served below the mount prefix. The reference mounts its whole ui router at <prefix>/ui (auth.router.ts:1640) and the config endpoint at /config within it (ui.router.ts:165).

It is exported because the four adapters mount the route and openapi.go describes it, and one constant is what keeps the mount and the spec from parting company — the way ResourceServerGatedRoutes is shared already.

Variables

View Source
var (
	ErrInvalidCredentials  = errors.New("auth: invalid credentials")
	ErrUserExists          = errors.New("auth: user already exists")
	ErrInvalidToken        = errors.New("auth: invalid token")
	ErrSessionNotFound     = errors.New("auth: session not found")
	ErrSessionRevoked      = errors.New("auth: session revoked")
	ErrWeakPassword        = errors.New("auth: password is too weak")
	ErrFeatureNotSupported = errors.New("auth: feature not supported by configured stores")
	ErrEmailNotVerified    = errors.New("auth: email not verified")
	ErrInvalidCode         = errors.New("auth: invalid code")
	ErrTwoFactorRequired   = errors.New("auth: two-factor authentication required")
	ErrAlreadyExists       = errors.New("auth: resource already exists")
	ErrTenantNotFound      = errors.New("auth: tenant not found")
	ErrRoleNotFound        = errors.New("auth: role not found")

	// ErrInvalidInput is a registration missing one of the two fields the route
	// cannot proceed without. It is deliberately not ErrWeakPassword: an absent
	// password is not a rejected one, and the default register handler on the
	// private dev line node-auth refuses the pair together, before it hashes or
	// stores anything (node-auth auth.router.ts:518-519, resolved against
	// DevLineRevision). The published reference at ReferenceRevision mounts
	// /register only when the host supplies options.onRegister and has no
	// default handler at all, so it has no code of its own here. See
	// Service.Register.
	ErrInvalidInput = errors.New("auth: email and password are required")

	// ErrEmailNotConfigured and ErrSMSNotConfigured mean the deployment has no
	// way to deliver the credential a send route just asked for. They are
	// deliberately not ErrFeatureNotSupported: that one says the configured
	// store cannot hold the column, and it is already on the wire as
	// NOT_IMPLEMENTED on these same routes. See delivery.go.
	ErrEmailNotConfigured = errors.New("auth: email delivery is not configured")
	ErrSMSNotConfigured   = errors.New("auth: sms delivery is not configured")
)
View Source
var (
	// HTTPErrOAuthState is the fix for #10. It is a 401 with a code because a
	// browser mid-flow has to be able to tell a tampered state from a provider
	// failure, and because the reference's own choice on this route is a JSON
	// error rather than a redirect (wire-contract.md §4, gap 5).
	HTTPErrOAuthState = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired OAuth state", Code: CodeOAuthStateInvalid}

	// Coded, unlike its passwordless sibling. The reference throws AuthError with
	// EMAIL_REQUIRED here (auth.router.ts:1498) but answers uncoded on
	// /magic-link/send (auth.router.ts:1111), so the two are not interchangeable.
	// See HTTPErrPasswordlessEmailRequired.
	HTTPErrLinkEmailRequired  = HTTPError{Status: http.StatusBadRequest, Message: "email is required", Code: CodeEmailRequired}
	HTTPErrTokenRequired      = HTTPError{Status: http.StatusBadRequest, Message: "token is required", Code: CodeTokenRequired}
	HTTPErrInvalidLinkToken   = HTTPError{Status: http.StatusBadRequest, Message: "Invalid account-link token", Code: CodeInvalidLinkToken}
	HTTPErrLinkTokenExpired   = HTTPError{Status: http.StatusBadRequest, Message: "Account-link token has expired", Code: CodeLinkTokenExpired}
	HTTPErrTargetUserNotFound = HTTPError{Status: http.StatusNotFound, Message: "Target user not found", Code: CodeUserNotFound}

	// HTTPErrLinkUnauthorized is the reference's second identity-resolution
	// failure. Its first one ("Authentication required", emitted when no pending
	// link store is configured at all) is unreachable here: this port resolves
	// both the stash and the link-token storage through the same
	// PendingLinkStore, so an absent store answers NOT_IMPLEMENTED before
	// identity resolution is ever attempted.
	HTTPErrLinkUnauthorized = HTTPError{Status: http.StatusUnauthorized, Message: "Authentication required or no pending link found", Code: CodeUnauthorized}

	// The provisioning refusals, all on the callback and all JSON rather than a
	// redirect — the reference's own choice for everything on that route that is
	// not the account conflict (wire-contract.md §4, gap 5). 403 rather than 401:
	// the provider did authenticate somebody, and the deployment will not admit
	// them, so repeating the flow cannot help.
	HTTPErrOAuthEmailNotVerified      = HTTPError{Status: http.StatusForbidden, Message: "OAuth email address is not verified", Code: CodeOAuthEmailNotVerified}
	HTTPErrOAuthEmailDomainNotAllowed = HTTPError{Status: http.StatusForbidden, Message: "OAuth email domain is not allowed", Code: CodeOAuthEmailDomainNotAllowed}
	HTTPErrOAuthUserNotProvisioned    = HTTPError{Status: http.StatusForbidden, Message: "No account is provisioned for this OAuth identity", Code: CodeOAuthUserNotProvisioned}
)

The error catalog for this group. Messages are the reference's strings where the reference has one.

View Source
var (
	HTTPErrTempTokenRequired = HTTPError{Status: http.StatusBadRequest, Message: "tempToken is required for 2FA mode", Code: CodeTempTokenRequired}
	HTTPErrInvalidTempToken  = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired temp token", Code: CodeInvalidTempToken}

	// HTTPErrInvalidStepUpToken is /2fa/verify's answer to the failure the
	// magic-link and SMS routes answer with HTTPErrInvalidTempToken. The
	// reference verifies the tempToken there through verifyAccessToken and lets
	// its AuthError through unchanged (auth.router.ts:862 vs :1096, :1144,
	// :1201, :1264), so the same failure has two codes depending on the 2FA
	// method. It is reproduced rather than harmonised: a client that special-
	// cases TOTP today would break if this route started agreeing with its
	// siblings.
	HTTPErrInvalidStepUpToken = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired access token", Code: CodeInvalidAccessToken}

	// Uncoded on purpose. The reference emits this same message WITHOUT a code on
	// /magic-link/send (auth.router.ts:1111) but WITH code EMAIL_REQUIRED on
	// /link-request (auth.router.ts:1498), so this and HTTPErrLinkEmailRequired are
	// not interchangeable. Do not merge them into one entry.
	HTTPErrPasswordlessEmailRequired = HTTPError{Status: http.StatusBadRequest, Message: "email is required"}
	HTTPErrUserIDOrEmailRequired     = HTTPError{Status: http.StatusBadRequest, Message: "userId or email is required"}
	HTTPErrUserIDRequired            = HTTPError{Status: http.StatusBadRequest, Message: "userId is required"}
	HTTPErrPhoneNotSet               = HTTPError{Status: http.StatusBadRequest, Message: "User does not have a phone number configured", Code: CodePhoneNotSet}

	HTTPErrInvalidSMSCode = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired SMS code"}

	// HTTPErrEmailNotConfigured is /magic-link/send's answer when nothing can
	// mail the link (magic-link.strategy.ts:12-14).
	HTTPErrEmailNotConfigured = HTTPError{Status: http.StatusInternalServerError, Message: "Email not configured", Code: CodeEmailNotConfigured}

	// HTTPErrSMSNotConfigured is /sms/send's answer when nothing can text the
	// code. The reference states this one twice with two different messages: the
	// router checks config.sms first and says "SMS is not configured"
	// (auth.router.ts:1179), and the strategy behind it says "SMS not
	// configured" (sms.strategy.ts:12) — no "is". Only the router's string is
	// ever on the wire, because its check runs before the strategy is reached,
	// so that is the one reproduced. The strategy's wording is not a second
	// catalog entry here; it has no route that can emit it.
	HTTPErrSMSNotConfigured = HTTPError{Status: http.StatusInternalServerError, Message: "SMS is not configured", Code: CodeSMSNotConfigured}

	// HTTPErrInvalidMagicLink covers both of the reference's magic-link
	// failures. The reference splits them — INVALID_MAGIC_LINK for a token it
	// cannot find, MAGIC_LINK_EXPIRED for one past its TTL
	// (magic-link.strategy.ts:41-49) — but Service.VerifyMagicLink collapses
	// the two into ErrInvalidToken, so the expiry code is not reachable from
	// here. Splitting the sentinel is the fix; both are 401 either way.
	HTTPErrInvalidMagicLink = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid magic link token", Code: CodeInvalidMagicLink}
	HTTPErrTokenMismatch    = HTTPError{Status: http.StatusUnauthorized, Message: "Token mismatch", Code: CodeTokenMismatch}

	// The two "Invalid TOTP code" entries differ only in status: /2fa/verify
	// answers 401 (auth.router.ts:869-872), /2fa/verify-setup answers 400
	// (:847-850).
	HTTPErrInvalidTOTPCode      = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid TOTP code"}
	HTTPErrInvalidTOTPSetupCode = HTTPError{Status: http.StatusBadRequest, Message: "Invalid TOTP code"}
	HTTPErrTOTPNotSetUp         = HTTPError{Status: http.StatusBadRequest, Message: "User not found or 2FA not set up"}

	HTTPErrTwoFactorRequiredForUser  = HTTPError{Status: http.StatusForbidden, Message: "Cannot disable 2FA: required for your account", Code: CodeTwoFactorRequired}
	HTTPErrTwoFactorRequiredByPolicy = HTTPError{Status: http.StatusForbidden, Message: "Cannot disable 2FA: required by system policy", Code: CodeTwoFactorRequired}
)

The catalog entries these routes write. Several carry no code: the reference answers those inline rather than through its AuthError path, and the omission is exact, not an oversight (wire-contract §3, "Shared mechanics").

View Source
var (
	// HTTPErrNoAccessToken and HTTPErrInvalidAccessToken are 403 and carry no
	// code. That looks wrong next to the 401s around them, but it is what the
	// reference middleware emits and the clients are built against it.
	HTTPErrNoAccessToken      = HTTPError{Status: http.StatusForbidden, Message: "No access token provided"}
	HTTPErrInvalidAccessToken = HTTPError{Status: http.StatusForbidden, Message: "Invalid or expired access token"}

	// HTTPErrSessionRevoked is the family's fast-logout signal: both browser
	// clients stop refreshing and log out on this exact code, so nothing else
	// may be returned for a revoked session.
	HTTPErrSessionRevoked = HTTPError{Status: http.StatusUnauthorized, Message: "Session has been revoked", Code: CodeSessionRevoked}

	HTTPErrNoRefreshToken      = HTTPError{Status: http.StatusUnauthorized, Message: "No refresh token provided"}
	HTTPErrInvalidRefreshToken = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid refresh token"}
	HTTPErrExpiredRefreshToken = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired refresh token", Code: CodeInvalidRefreshToken}

	HTTPErrInvalidCredentials = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid credentials", Code: CodeInvalidCredentials}
	HTTPErrEmailNotVerified   = HTTPError{Status: http.StatusForbidden, Message: "Email address is not verified", Code: CodeEmailNotVerified}
	HTTPErrTwoFactorRequired  = HTTPError{Status: http.StatusForbidden, Message: "Two-factor authentication required", Code: CodeTwoFactorRequired}
	HTTPErrInvalidToken       = HTTPError{Status: http.StatusUnauthorized, Message: "Invalid or expired token", Code: CodeInvalidToken}
	HTTPErrCSRFInvalid        = HTTPError{Status: http.StatusForbidden, Message: "CSRF token validation failed", Code: CodeCSRFInvalid}
	// HTTPErrUserNotFound carries no code: every reference site that emits the
	// literal "User not found" emits it code-less (GET /me, /change-password,
	// /sms/send, the magic-link 2fa branch). The one coded USER_NOT_FOUND in the
	// reference is a different message ("Target user not found", /link-request),
	// so a route reusing this value must not gain a code by accident.
	HTTPErrUserNotFound   = HTTPError{Status: http.StatusNotFound, Message: "User not found"}
	HTTPErrNotImplemented = HTTPError{Status: http.StatusNotImplemented, Message: "Feature not supported by the configured stores", Code: CodeNotImplemented}

	// HTTPErrInternal deliberately carries neither a code nor the underlying
	// error text: an unexpected failure must not describe itself to the caller.
	HTTPErrInternal = HTTPError{Status: http.StatusInternalServerError, Message: "Internal server error"}

	HTTPErrUserExists   = HTTPError{Status: http.StatusConflict, Message: "User already exists", Code: CodeUserExists}
	HTTPErrWeakPassword = HTTPError{Status: http.StatusBadRequest, Message: "Password is too weak", Code: CodeWeakPassword}
	HTTPErrInvalidBody  = HTTPError{Status: http.StatusBadRequest, Message: "Invalid request body", Code: CodeInvalidBody}
	// HTTPErrInvalidInput carries the dev line's literal message: a body that
	// names neither field is not a weak password, and answering WEAK_PASSWORD
	// there tells the user to pick a better one when they typed none at all.
	HTTPErrInvalidInput = HTTPError{Status: http.StatusBadRequest, Message: "Email and password are required", Code: CodeInvalidInput}
)

The error catalog. Messages are the reference's own strings, because a client that surfaces the server message must not read differently per implementation.

View Source
var (
	// HTTPErrResetTokenStoreMissing and the two below it are 500, not 501: the
	// reference reports a store that cannot perform the flow as an internal
	// error (auth.router.ts:805-808, 937-940, 1000-1003, 1042-1045). A client
	// that treats 501 as "feature absent" and 500 as "server broken" therefore
	// has to see 500 here, which is why these routes do not go through
	// HTTPErrorFor's ErrFeatureNotSupported → 501 mapping.
	HTTPErrResetTokenStoreMissing        = HTTPError{Status: http.StatusInternalServerError, Message: "UserStore does not implement findByResetToken"}
	HTTPErrEmailVerificationStoreMissing = HTTPError{Status: http.StatusInternalServerError, Message: "UserStore does not implement email verification"}
	HTTPErrChangeEmailStoreMissing       = HTTPError{Status: http.StatusInternalServerError, Message: "UserStore does not implement change-email"}

	HTTPErrInvalidResetToken       = HTTPError{Status: http.StatusBadRequest, Message: "Invalid reset token"}
	HTTPErrInvalidVerifyToken      = HTTPError{Status: http.StatusBadRequest, Message: "Invalid verification token"}
	HTTPErrInvalidEmailChangeToken = HTTPError{Status: http.StatusBadRequest, Message: "Invalid email-change token"}
	HTTPErrVerifyTokenRequired     = HTTPError{Status: http.StatusBadRequest, Message: "Token is required"}
	HTTPErrEmailAlreadyVerified    = HTTPError{Status: http.StatusBadRequest, Message: "Email is already verified"}

	// HTTPErrCurrentPasswordIncorrect is a 401 with no code, unlike the coded
	// INVALID_CREDENTIALS a failed login returns. Reproduced as-is even though it
	// is a known hazard: /change-password is absent from the Angular
	// interceptor's no-retry list, so a wrong current password costs a pointless
	// refresh-and-replay round trip (wire-contract §2, [MISMATCH]).
	HTTPErrCurrentPasswordIncorrect = HTTPError{Status: http.StatusUnauthorized, Message: "Current password is incorrect"}
	HTTPErrNewPasswordRequired      = HTTPError{Status: http.StatusBadRequest, Message: "New password is required"}

	// HTTPErrEmailInUse carries no code, so a client cannot branch on it — only
	// on the 409 status.
	HTTPErrEmailInUse = HTTPError{Status: http.StatusConflict, Message: "Email address is already in use"}

	HTTPErrPasswordRequired = HTTPError{
		Status:  http.StatusForbidden,
		Message: "You must set a password before you can change your email address.",
		Code:    CodePasswordRequired,
	}
)

The §2 error catalog. Messages are the reference's own strings, down to the "UserStore does not implement …" wording it uses for an incapable store.

View Source
var ErrDeliveryFailed = errors.New("auth: delivery failed after the credential was stored")

ErrDeliveryFailed marks a send that failed after the credential was already stored. It is joined to the transport's own error rather than replacing it, so a caller can still read the cause, and it exists for exactly one reason: /forgot-password has to tell a delivery failure apart from a store failure in order to swallow the first and report the second (Auth.ForgotPassword).

It maps to no catalog entry of its own. HTTPErrorFor does not know it, so it falls through to the generic 500 — which is what the reference answers for a throwing mailer, and what the two authenticated routes therefore answer.

View Source
var ErrMagicLinkOwnerMismatch = errors.New("auth: magic link belongs to another user")

ErrMagicLinkOwnerMismatch is a valid magic link presented as a second factor for somebody else: the link verified, but it does not belong to the user the step-up token names. It is a distinct sentinel rather than ErrInvalidToken because the wire answer differs (TOKEN_MISMATCH, not INVALID_MAGIC_LINK) and because the two failures mean different things — one is a bad link, the other is a good link on the wrong account.

View Source
var ErrOAuthAccountConflict = errors.New("auth: oauth account conflict")

ErrOAuthAccountConflict is the sentinel every account conflict unwraps to, so a caller can test for the outcome with errors.Is without naming the type. The details — which address, which provider account — are on OAuthAccountConflictError, which errors.As recovers.

View Source
var HTTPErrInvalidTokenRS = HTTPError{
	Status:  http.StatusUnauthorized,
	Message: "Invalid or expired access token",
	Code:    CodeInvalidToken,
}

HTTPErrInvalidTokenRS is what the resource-server middleware answers for any verification failure: 401 with code INVALID_TOKEN (jwks-auth.middleware.ts:77).

It shares its message with HTTPErrInvalidAccessToken and differs in status and code, which is the reference difference too: the session middleware answers 403 and no code (auth.middleware.ts), and this one answers 401 with a code. Both messages are the same string, so a client that only surfaces the message reads the same sentence either way.

View Source
var HTTPErrSessionNotFound = HTTPError{Status: http.StatusNotFound, Message: "Session not found"}

HTTPErrSessionNotFound is the answer to DELETE /sessions/{handle} for a handle that does not exist and for one that belongs to somebody else — deliberately the same body, so the route cannot be used to probe for live sessions (auth.router.ts:762-767). It is written by the route rather than mapped in HTTPErrorFor, because ErrSessionNotFound means "unusable refresh token" (401) everywhere else.

Functions

func APIKeyMiddleware

func APIKeyMiddleware(store APIKeyStore, requiredScopes []string) func(http.Handler) http.Handler

func AccessTokenFromRequest added in v0.2.0

func AccessTokenFromRequest(r *http.Request) string

AccessTokenFromRequest applies the reference's extraction order: the Authorization header first, then the access-token cookie.

func BearerToken added in v0.2.0

func BearerToken(header string) string

BearerToken extracts the token from an Authorization header.

func BuildRS256JWT added in v0.5.0

func BuildRS256JWT(signer crypto.Signer, kid string, claims map[string]any) (string, error)

BuildRS256JWT creates a signed RS256 JWT using only standard library packages: header {"alg":"RS256","typ":"JWT","kid":kid}, the claims as given, both unpadded base64url, and the PKCS#1 v1.5 signature over the SHA-256 of base64url(header) + "." + base64url(claims), as RFC 7515 section 5.1 and RFC 7518 section 3.3 require.

The signer is asked for exactly one thing: Sign(rand.Reader, digest, crypto.SHA256) with the 32-byte SHA-256 digest of the signing input. An *rsa.PrivateKey answers with PKCS#1 v1.5, which is what RS256 is; a signer backed by a KMS, an HSM or another process must do the same when it sees crypto.SHA256 as the option (AWS KMS: RSASSA_PKCS1_V1_5_SHA_256 on a MessageType DIGEST request), and its Public must return the matching *rsa.PublicKey for the key to be published.

A signer whose Public is not an *rsa.PublicKey — an ECDSA or Ed25519 key — is refused with an error before Sign is asked: the alternative is a token whose header says RS256 over a signature no RS256 verifier accepts, and no error anywhere. NewIDP makes the same check once at construction; this one is for a host calling BuildRS256JWT on its own, and it consults Public on every call.

func CSRFMiddleware

func CSRFMiddleware(cfg HTTPConfig) func(http.Handler) http.Handler

CSRFMiddleware distributes and enforces the double-submit token.

Distribution mirrors the reference's router-level auto-init: a request that arrives without a readable CSRF cookie gets a fresh one.

Enforcement follows the reference matrix. A request is checked when it is an unsafe method, on a mounted route, that route is not exempt, the request presents no bearer credential, and either it carries an access-token cookie or the route does its own manual check in the reference:

enforced = mutating && mounted && !exempt && !usingBearerCredential
           && (accessTokenCookiePresent || manualCheckRoute)

Safe methods, bearer requests, the unauthenticated routes in csrfExemptRoutes and — for every route but csrfManualCheckRoutes — requests with no cookie credential are passed through. The last of those is the reference's ordering: see csrfEnforced.

func CompileProfileMap added in v0.4.0

func CompileProfileMap(m map[string]string) (func(map[string]any) (OAuthUserInfo, error), error)

CompileProfileMap compiles a declarative profile map into the function OAuthProvider.MapProfile takes. It is what NewOAuthService runs over every OAuthProvider.ProfileMap, exported so a deployment can validate a map it loaded from configuration before it wires anything.

The keys are id, email, emailVerified, name and picture — the fields of the reference profile a mapProfile hook returns (generic-oauth.strategy.ts:71-77). Any other key is an error, and so is a map without id, since the profile it would produce can never identify a user. The values are expressions in this grammar, which is this port's own (the reference takes a function):

expr    = alt ("??" alt)*
alt     = path | literal
path    = "$" ("." key | "[" index "]")+
key     = one or more letters, digits, "_" or "-"
index   = one or more decimal digits
literal = a double-quoted string, allowed only as the last alternative

Whitespace around an alternative is ignored. Anything else — "$" alone, a single-quoted literal, a wildcard, a filter, a slice, a function call, a quoted key — is a compile error naming the expression.

Evaluation follows JavaScript's ?? operator: the alternatives are tried in order and the first path that resolves to a value that is neither missing nor null wins, so an empty string, false or 0 is a value and stops the chain; a literal always resolves. A string is taken as is, a number is rendered as JavaScript's String() renders it (an integer-valued number has no decimal point and no exponent below 1e21) and a boolean becomes "true" or "false"; a value that is an object or an array is an evaluation error. emailVerified is the one non-string field: its value must be a boolean or the strings "true" and "false", and a literal that is neither is refused at compile time. id is required — an id that resolves to nothing, or to the empty string, is an evaluation error; the other four are optional and stay empty (emailVerified nil) when no alternative resolves.

The returned function fills ProviderID, Email, Name, AvatarURL, EmailVerified and Raw; Provider is left for the service to set.

func ContextWithUser added in v0.6.0

func ContextWithUser(ctx context.Context, user User) context.Context

ContextWithUser returns ctx carrying user as the authenticated principal.

func CookieValue added in v0.2.0

func CookieValue(r *http.Request, base string) string

CookieValue reads a cookie honouring the family's __Host- → __Secure- → bare priority. Every read site must go through it, otherwise a deployment that switches Secure on stops recognising its own cookies.

func DecodeOptionalJSON added in v0.2.0

func DecodeOptionalJSON(w http.ResponseWriter, r *http.Request, dst any) bool

DecodeOptionalJSON decodes a JSON request body into dst, tolerating an absent or empty one, and writes the error envelope when the body is present but malformed.

Every route in this group accepts an empty body: the reference runs express.json(), which leaves req.body as {} rather than failing, and the Flutter client posts /send-verification-email with no body at all. Rejecting a malformed body with 400 INVALID_BODY is the port's own convention, already applied by /register and /login.

func DecodeOptionalJSONBody added in v0.2.0

func DecodeOptionalJSONBody(r *http.Request, dst any) error

DecodeOptionalJSONBody decodes r's JSON body into dst, treating an absent or zero-length body as the empty object rather than as an error.

The reference runs behind express.json(), which leaves req.body = {} when the request carried no body at all; PATCH /profile and POST /add-phone then read optional fields off it, so a bodyless call is a 200 no-op there and not a 400 (§3.5, §3.6). Without a shared decoder the four adapters disagree about exactly this: echo's Bind returns nil for a zero-length body, while encoding/json and gin's ShouldBindJSON both surface io.EOF as a decode error.

A body that is present but is not valid JSON is still an error: express.json() rejects that too, and tolerating it would let a typo'd payload be silently applied as "change nothing".

func EmailChangeConfirmURL added in v0.3.0

func EmailChangeConfirmURL(base, token string) string

EmailChangeConfirmURL builds the change-email confirmation link.

func EmailVerificationURL added in v0.3.0

func EmailVerificationURL(base, token string) string

EmailVerificationURL builds the verify-email link.

func GenerateOpenAPISpec

func GenerateOpenAPISpec(info OpenAPIInfo) map[string]any

GenerateOpenAPISpec returns an OpenAPI 3.0 spec for the mounted auth endpoints.

func IsBearerRequest added in v0.2.0

func IsBearerRequest(r *http.Request) bool

IsBearerRequest reports whether the caller asked for token delivery in the response body instead of cookies.

func MagicLinkURL added in v0.3.0

func MagicLinkURL(base, token string) string

MagicLinkURL builds the link the reference mails: the base with any trailing slash removed, then "/magic-link/verify?token=<token>" (magic-link.strategy.ts:23-27). Reproduced here so that a deployment writing its own sender does not have to guess the shape the verify route and the shipped clients expect.

base is the address the auth routes are reachable at from a mailbox — "https://app.example.com/auth". The reference derives it per request from email.siteUrl and the request Origin (resolveSiteUrl / buildUiLink, wire-contract §4), and so do the adapters here: MagicLinkDelivery.LinkBase is that value (Auth.ResolveSiteURL, HTTPConfig.LinkBase), and a custom sender passes it straight in. An empty base yields a relative link, which the reference also produces when siteUrl is unset — it is a misconfiguration in both, not a special case handled here.

The token is interpolated raw. randomToken emits base64url, whose alphabet needs no query escaping, and the reference interpolates its hex token raw for the same reason.

func OAuthNotConfiguredError added in v0.2.0

func OAuthNotConfiguredError(provider string) error

OAuthNotConfiguredError is the "<Provider> OAuth not configured" stub as an error, so an adapter can answer it without reaching into the flow methods.

func OpenAPIHandler added in v0.7.0

func OpenAPIHandler(info OpenAPIInfo) http.Handler

OpenAPIHandler serves GenerateOpenAPISpec(info) as application/json, which is what the reference's route does with the document its own generator returns (auth.router.ts:1658-1671).

The document is built once, here, and not per request: it is a pure function of info, and nothing the handler sees can change it. The bytes are stable too — encoding/json writes map keys in sorted order — so a caching client sees one body per configuration.

The handler carries no middleware of its own, as the reference's route carries no guard: no auth, no session, nothing to present a credential to. The adapters put CSRFMiddleware in front of it, which on a GET only distributes the cookie the reference's router-level auto-init distributes (auth.router.ts:529-538) and rejects nothing. A host mounting it by hand should leave it as public as that, or keep it off: it describes routes, not data, but it is still a description of the surface an attacker would otherwise have to guess.

func ParseRSAPrivateKeyPEM added in v0.5.0

func ParseRSAPrivateKeyPEM(pemText string) (*rsa.PrivateKey, error)

ParseRSAPrivateKeyPEM decodes a PEM-encoded RSA private key, accepting both the PKCS#8 form the reference generates ("PRIVATE KEY", jwks.service.ts:152) and the PKCS#1 form openssl genrsa writes ("RSA PRIVATE KEY"). Anything else — no PEM block, another block type, a PKCS#8 key of a different algorithm — is an error. The result is what IDPConfig.Signer takes: an *rsa.PrivateKey is a crypto.Signer.

func PasswordResetURL added in v0.3.0

func PasswordResetURL(base, token string) string

PasswordResetURL, EmailVerificationURL and EmailChangeConfirmURL build the links the reference mails, so a deployment writing its own sender does not have to guess the shape.

base is the address the auth routes are reachable at from a mailbox. The reference derives it per request (resolveSiteUrl / buildUiLink, wire-contract §4) and so do the adapters here — each delivery's LinkBase is that value, see MagicLinkURL — so a custom sender passes delivery.LinkBase in, falling back to a base of its own when it is empty.

One caveat carries over from the reference and is worth knowing before these links reach a real mailbox: /reset-password and /change-email/confirm are POST-only routes, so a browser following either link lands on something it cannot GET (wire-contract §2 notes the same about the reference with its UI disabled). A deployment points base at the front end that owns those two pages; /verify-email is a GET and works directly.

func RateLimitMiddleware added in v0.7.0

func RateLimitMiddleware(cfg HTTPConfig) func(http.Handler) http.Handler

RateLimitMiddleware returns the configured rate limiter, or a pass-through when HTTPConfig.RateLimiter is nil.

It is the counterpart of CSRFMiddleware and the adapters compose the two in that order — this one outermost — so that a refused request never reaches either the CSRF comparison or the auth middleware. See HTTPConfig.RateLimiter for the reference citations.

func RefreshTokenFromRequest added in v0.2.0

func RefreshTokenFromRequest(r *http.Request) string

RefreshTokenFromRequest applies the reference's acceptance order for /refresh: a refreshToken in the body wins, otherwise the cookie. A missing or empty body is not an error — cookie-mode refresh sends no body at all.

func ResourceServerGatedRoutes added in v0.6.0

func ResourceServerGatedRoutes() map[string]string

ResourceServerGatedRoutes is the credential surface HTTPConfig.ResourceServer unmounts, as method-keyed paths relative to the mount prefix.

Nineteen routes, and the rule behind the list is one sentence: a route is here when it mints, delivers or consumes a credential. Nothing more is claimed for it — in particular, this is not the set of routes that need a local user database; see below.

POST   /register                  create an account and a password
POST   /login                     exchange a password for a session
POST   /refresh                   rotate this instance own session
POST   /logout                    revoke this instance own session
POST   /forgot-password           mint and mail a reset token
POST   /reset-password            spend a reset token
POST   /change-password           replace a password
POST   /send-verification-email    mint and mail a verification token
GET    /verify-email              spend a verification token
POST   /change-email/request      mint and mail an email-change token
POST   /change-email/confirm      spend an email-change token
POST   /magic-link/send           mint and mail a login credential
POST   /magic-link/verify         spend it for a session
POST   /sms/send                  mint and text a login credential
POST   /sms/verify                spend it for a session
POST   /2fa/setup                 enrol a TOTP secret
POST   /2fa/verify-setup          confirm the enrolment
POST   /2fa/verify                spend a second factor for a session
POST   /2fa/disable               drop the enrolment

What stays mounted is GET /me, the session routes, PATCH /profile, POST /add-phone, DELETE /account, and the whole OAuth and account-linking group. On an Auth built WithIDP, the JWKS route, <prefix>/userinfo and <prefix>/.well-known/openid-configuration stay too — and <prefix>/authorize and <prefix>/token do not.

Those four are not in this map, because this map is the base surface's and they are routes only under auth.WithIDP; the two gated ones are named by OIDCMount.ResourceServerGated, which every adapter reads from the same walk that mounts them, and by OpenAPIInfo.OIDC, which drops them from the spec in the same breath. The reason they are gated is the definition at the top of this file: POST <prefix>/authorize reads an email, a password and a tenant off the form and hands them to Service.Login, and POST <prefix>/token spends the resulting code for this instance's own HS256 session pair, so leaving them mounted would hand a deployment that set this flag to unmount /login and /refresh a password-accepting endpoint and a session-minting one in two hops. Discovery is metadata, like the JWKS document beside it, and userinfo reads a bearer token and returns a profile, which is GET /me's shape and stays for GET /me's reason.

Those still need a local user store, and saying otherwise would be a trap: /me goes through Service.Authenticate to users.GetUserByID (service.go:236,249-255); /profile, /add-phone and /account read and write the user store; and GET /oauth/{provider}/callback provisions a user and mints a local session, which is a credential this list otherwise gates. They stay because the reference mounts them and because a deployment that does have a store — a full instance that also accepts another issuer's bearer tokens — uses them. Resource-server mode is about credentials, not about store independence: the store-less deployment is the one for ResourceServerMiddleware, whose two paths read no store at all, and such a deployment should not be mounting the auth router's account routes in the first place. Gating them here instead would take routes away from the hybrid deployment, which is the commoner one, to protect a deployment that has no reason to mount them; that is the decision, and it is why the list is what it is rather than "every route that touches the user store".

The reference gates six of these — /login, /logout, /refresh, /register, /forgot-password and /reset-password (auth.router.ts:541, :590, :622, :713, :777, :802) — and leaves the other thirteen mounted, where they fail at runtime against a database that is not there. Gating all nineteen is the resource-server-gates-all-credential-routes deviation; see CompatibilityNotes.

The returned map is freshly built, so a caller may edit it.

func ResourceServerMiddleware added in v0.6.0

func ResourceServerMiddleware(a *Auth, cfg ResourceServerConfig) func(http.Handler) http.Handler

ResourceServerMiddleware guards a host route in resource-server mode (jwks-auth.middleware.ts:37-80).

A bearer token is verified against the remote JWKS; an access-token cookie is verified against this instance own HS256 secret. Neither path reads a store: the principal is built from the verified claims in both cases, which is what makes the middleware usable on the deployment it is named after — an instance with no user database. The reference keeps the cookie path for the dashboard that hosts the resource server and may itself be logged in, and verifies it with tokenService.verifyAccessToken (jwks-auth.middleware.ts:70-73), a bare jwt.verify against the HS256 secret with no store access of any kind (token.service.ts:143-150).

The two failures are the reference failures, and they differ:

  • no token at all — neither header nor cookie — is 403 {"error":"No access token provided"}, code-less, the same body the session middleware writes (jwks-auth.middleware.ts:56-59);
  • anything else is 401 {"error":"Invalid or expired access token", "code":"INVALID_TOKEN"} (:76-78).

The cookie path here is deliberately not Auth.Authenticate, which the adapters' session Middleware uses. Authenticate reads the user store and checks the session for revocation; verifyAccessToken does neither, so this middleware does neither. Two consequences follow, both the reference's: a cookie whose subject is not in this instance store is still accepted, and a revoked session is accepted until its access token expires — SESSION_REVOKED is a code of the session middleware, and this middleware cannot emit it. A deployment that wants revocation checked on every call mounts the session Middleware instead; it is the one with a database behind it.

The user reaches the handler through UserFromContext on the request context, which is what every adapter UserFromContext reads.

One JWKSClient is built per call, so the key cache is shared by every request through the returned middleware and not beyond it. The reference caches one client per URL for the whole process (jwks-auth.middleware.ts:10-23); a host that mounts two middlewares against one issuer and wants one cache builds the client once with NewJWKSClient and shares it. Nothing on the wire depends on which it does.

func ResourceServerPrincipal added in v0.6.0

func ResourceServerPrincipal(r *http.Request, a *Auth, client *JWKSClient, issuer string) (User, HTTPError, bool)

ResourceServerPrincipal verifies the credential on r the way ResourceServerMiddleware does and returns the principal, the error envelope to write when there is none, and whether verification succeeded.

It is exported for the framework adapters, which have to put the user into their own context rather than into the request context, and for a host writing a middleware for a framework this repository does not ship.

func SMSCodeMessage added in v0.3.0

func SMSCodeMessage(code string) string

SMSCodeMessage is the text the reference texts (sms.strategy.ts:20). It is on a wire of its own — a real handset — so the literal is reproduced rather than reworded, and it lives here so a custom SMSCodeSender can reuse it instead of drifting from the rest of the family.

func ServeAdminUI

func ServeAdminUI() http.Handler

ServeAdminUI returns an http.Handler serving the embedded admin dashboard HTML.

func ServeAuthJS

func ServeAuthJS() http.Handler

ServeAuthJS returns an http.Handler serving the embedded auth.js browser SDK.

func ServeAuthUI

func ServeAuthUI() http.Handler

ServeAuthUI returns an http.Handler serving the embedded auth UI HTML (login/register).

func ServeSSE

func ServeSSE(hub *SseHub, channel string) http.HandlerFunc

ServeSSE is an http.HandlerFunc that streams SSE to a connected client. channel is typically the authenticated user's ID.

func SessionHandleParam added in v0.2.0

func SessionHandleParam(r *http.Request, raw string) string

SessionHandleParam normalises the {handle} path parameter: exactly one percent-decode, the way the reference's decodeURIComponent does (auth.router.ts:761). One family client URI-escapes the handle and the other posts it verbatim, so both forms have to name the same session.

The decode deliberately does not start from the router's own path parameter, because the four routers disagree about what they hand over: chi (via r.PathValue, which chi populates from its escaped route path) and echo (which routes on r.URL.RawPath when it is set) pass the segment still encoded, while net/http and gin pass it already decoded. Unescaping whatever arrives is therefore one decode on two adapters and two on the other two, and no inspection of the string can tell the cases apart — "ses_50%25off" is both a valid decoded handle and the encoded form of "ses_50%off".

r.URL.EscapedPath() is router-independent (RawPath when the request carried one, the re-encoded Path otherwise), so taking the last segment of it and decoding once gives all four adapters the same handle. raw is kept as the fallback for a path that yields no segment.

A handle containing an encoded slash is still not recoverable — the path has already been split on it — but the handles this package issues are "ses_" plus hex.

func SwaggerUIHandler added in v0.7.0

func SwaggerUIHandler(specURL string) http.Handler

SwaggerUIHandler serves the reference's Swagger UI page for the document at specURL, as text/html (auth.router.ts:1674-1677). An empty specURL means the reference's own default, "./openapi.json" (openapi.ts:1646).

The page is rendered once, here: it is a pure function of specURL.

func TwoFactorMode added in v0.2.0

func TwoFactorMode(mode string) bool

TwoFactorMode reports whether a magic-link or SMS request asked for the second-factor branch.

The comparison is literal and case-sensitive because the reference's is (`mode === '2fa'`, auth.router.ts:1087, :1134, :1192, :1255): "login", the empty string, and anything else all take the login branch. That is what makes the two shipped clients interchangeable — Angular sends mode:"login", Flutter omits the field — so loosening this comparison silently reroutes one of them.

func VerifyEmailToken added in v0.2.0

func VerifyEmailToken(r *http.Request) string

VerifyEmailToken reads the token GET /verify-email carries. It is a query parameter, not a body field, and the route is the only GET in this group.

The value is used exactly as sent: the reference tests it with `if (!token)` (auth.router.ts:972), so a token of one space is truthy there and fails the lookup with 400 "Invalid verification token" rather than 400 "Token is required". Trimming here would be friendlier but would move that request into the other error, so the reference's answer wins.

func VerifyRS256 added in v0.6.0

func VerifyRS256(ctx context.Context, token string, client *JWKSClient, expectedIssuer string) (map[string]any, error)

VerifyRS256 verifies an RS256 token against the keys client publishes and returns its claims — the reference verifyWithJwks (token.service.ts:102-141).

The shape is the reference shape, step for step:

  1. the header is decoded without verifying, only to read kid;
  2. a token with no kid is refused;
  3. the header alg must be RS256. This check is the reference's algorithms: ['RS256'] (token.service.ts:136) hoisted above the key lookup: the reference reaches the network first and only then refuses an alg:none or alg:HS256 token, which means a junk token can cost an outbound fetch. Nothing on the wire changes — both refusals are the same 401 INVALID_TOKEN — only which of the five messages is logged;
  4. the key is looked up; if the cache does not carry it, the cache is invalidated and the lookup retried exactly once, which is how a rotation is picked up mid-TTL. A second miss is refused, and so is the first when the cached document is younger than MinRefreshInterval (see invalidateForRotation and the jwks-unknown-kid-refetch-is-rate-limited deviation);
  5. the signature is checked with RS256 pinned, and exp and nbf are honoured;
  6. when expectedIssuer is set, iss must equal it — a plain string comparison, as the reference does it (token.service.ts:137).

Every refusal of the token itself wraps ErrInvalidToken, so HTTPErrorFor maps it to 401 INVALID_TOKEN. A failure to read the JWKS at all does not: a transport error, a non-200 status and an unparseable document surface from GetKey unchanged and unwrapped, and HTTPErrorFor sends them to HTTPErrInternal, because an issuer outage is this server's 5xx and not the caller's bad credential. ResourceServerMiddleware answers 401 either way, as the reference does (its catch does not distinguish them either, jwks-auth.middleware.ts:76-78); a host calling VerifyRS256 directly sees the difference and should.

The claims are returned as the decoded JSON object, unchanged: this verifier does not know what an issuer puts in its tokens, and a resource server is entitled to read all of it.

func VerifyWebhookSignature

func VerifyWebhookSignature(secret string, body []byte, sigHeader string) error

VerifyWebhookSignature validates an inbound webhook signature.

func WriteHTTPError added in v0.2.0

func WriteHTTPError(w http.ResponseWriter, e HTTPError)

WriteHTTPError writes the error envelope.

func WriteJSON added in v0.2.0

func WriteJSON(w http.ResponseWriter, status int, payload any)

WriteJSON writes payload as the entire response body.

func WriteServiceError added in v0.2.0

func WriteServiceError(w http.ResponseWriter, err error)

WriteServiceError maps a service sentinel and writes it.

func WriteSuccess added in v0.2.0

func WriteSuccess(w http.ResponseWriter, status int, extra map[string]any)

WriteSuccess writes the {"success": true} envelope, merging any extra top-level fields a route contributes.

func WriteTwoFactorChallenge added in v0.3.0

func WriteTwoFactorChallenge(w http.ResponseWriter, challenge TwoFactorChallenge)

WriteTwoFactorChallenge writes the login challenge as the entire response body.

No cookie is set on either branch, in either delivery mode: nothing has been issued yet, so there is nothing to deliver. That is the reference's behaviour too — the 2FA branch returns before issueTokens is ever reached (auth.router.ts:571-578 precedes :580-581).

Types

type APICompatibilityNotes added in v0.3.0

type APICompatibilityNotes struct {
	// ContractTarget names the clients the wire shape is pinned to.
	ContractTarget string `json:"contractTarget"`
	// ReferenceRevision is ReferenceRevision, carried in the value so a caller
	// that serialises these notes ships the revision its citations need.
	ReferenceRevision string `json:"referenceRevision"`
	// CookieConventions and BearerConventions summarise the two delivery modes.
	CookieConventions string `json:"cookieConventions"`
	BearerConventions string `json:"bearerConventions"`
	// KnownDeviations is the register, in no significant order.
	KnownDeviations []Deviation `json:"knownDeviations"`
}

APICompatibilityNotes is the contract this port targets, plus every known deviation from it.

func CompatibilityNotes added in v0.3.0

func CompatibilityNotes() APICompatibilityNotes

CompatibilityNotes returns the wire contract this port targets and the known deviations from it.

The result is freshly built on every call and shares nothing with the package, so a caller may sort, filter or edit it without affecting anyone else's view.

type APIKeyRecord

type APIKeyRecord struct {
	ID         string
	Prefix     string
	Name       string
	ServiceID  string
	KeyHash    string
	Scopes     []string
	AllowedIPs []string
	IsActive   bool
	ExpiresAt  *time.Time
	LastUsedAt *time.Time
}

APIKeyRecord stores hashed key material and policy constraints.

type APIKeyService

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

APIKeyService issues and verifies API keys. Create stores a bcrypt hash of the key, so it carries the same cost knob as Config.BcryptCost — an operator who raises the password cost would otherwise still get key hashes pinned at the default, with no way to reach them.

func NewAPIKeyService

func NewAPIKeyService(bcryptCost int) *APIKeyService

NewAPIKeyService returns a service that hashes new keys at bcryptCost.

As with Config.BcryptCost, zero means unset and resolves to bcrypt.DefaultCost. Verify does not hash, so a service used only for verification may be built with 0 regardless of the cost its keys were created at: bcrypt reads the cost out of the stored hash.

func (*APIKeyService) Create

func (s *APIKeyService) Create(ctx context.Context, store APIKeyStore, name, serviceID string, scopes, allowedIPs []string, expiresAt *time.Time) (rawKey string, record APIKeyRecord, err error)

func (*APIKeyService) Verify

func (s *APIKeyService) Verify(ctx context.Context, store APIKeyStore, rawKey string, ip string, requiredScopes []string) (APIKeyRecord, error)

type APIKeyStore

type APIKeyStore interface {
	Save(ctx context.Context, key APIKeyRecord) error
	FindByPrefix(ctx context.Context, prefix string) (APIKeyRecord, error)
	Revoke(ctx context.Context, id string) error
	UpdateLastUsed(ctx context.Context, id string, when time.Time) error
}

APIKeyStore persists and resolves API keys.

type AddPhoneInput added in v0.2.0

type AddPhoneInput struct {
	UserID      string
	TenantID    string
	PhoneNumber string
}

AddPhoneInput is the payload behind POST /add-phone. An empty PhoneNumber clears the number, as the reference's nullable phoneNumber does.

type Auth

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

Auth is the top-level entrypoint configured with functional options.

func New

func New(opts ...Option) (*Auth, error)

New creates a configured Auth instance from the package defaults.

func NewWithConfig added in v0.2.0

func NewWithConfig(cfg Config, opts ...Option) (*Auth, error)

NewWithConfig creates a configured Auth instance from a pre-populated Config, with any Options applied in order on top of it.

New builds its Config internally and the exported Option set has always been narrower than Config, so fields with no matching Option — the individual TTLs, EmailVerificationMode, and until now BcryptCost — were unreachable from outside the package (#25). Accepting the struct closes that whole class of gap at once and cannot drift as Config grows, which one-Option-per-field would. The Options remain as sugar and still compose with a supplied Config.

cfg is validated by NewService exactly as New's is; a hand-built Config that omits, say, the TTLs is rejected rather than silently defaulted.

func (*Auth) Authenticate added in v0.5.0

func (a *Auth) Authenticate(ctx context.Context, accessToken string) (User, error)

Authenticate delegates to Service.Authenticate: Me without the custom-claim enrichment, which is what the adapters' Middleware calls.

func (*Auth) ChangePassword added in v0.2.0

func (a *Auth) ChangePassword(ctx context.Context, in ChangePasswordInput) error

ChangePassword performs POST /change-password.

A passwordless account — OAuth-only, or magic-link-only — skips the current-password comparison entirely and may set an initial password by supplying newPassword alone: in the reference the compare sits inside `if (user.password)` and the `else if` only fires when *both* fields are falsy, so such a caller falls through to the hash-and-store (wire-contract §2 "Passwordless-account path", auth.router.ts:916-928). It is the only way an account with no password ever acquires one.

Service.ChangePassword compares unconditionally (service.go:276), and verifyPassword("", "") is a bcrypt error, so delegating the passwordless case to it reports a password the account does not have as incorrect. The passwordless half is therefore handled here rather than by widening Service.ChangePassword: a library consumer calling the service directly may deliberately not want a caller setting a password without presenting one, and this PR's contract is the HTTP surface. The service-level divergence is filed upstream.

func (*Auth) CleanupExpiredSessions added in v0.2.0

func (a *Auth) CleanupExpiredSessions(ctx context.Context) (int, error)

CleanupExpiredSessions delegates to Service.CleanupExpiredSessions.

func (*Auth) ConfirmEmailChange added in v0.2.0

func (a *Auth) ConfirmEmailChange(ctx context.Context, in ConfirmEmailChangeInput) error

ConfirmEmailChange delegates to Service.ConfirmEmailChange.

func (*Auth) DeleteAccount

func (a *Auth) DeleteAccount(ctx context.Context, in DeleteAccountInput) error

DeleteAccount delegates to Service.DeleteAccount.

func (*Auth) DisableTOTP added in v0.2.0

func (a *Auth) DisableTOTP(ctx context.Context, userID, tenantID string) error

DisableTOTP delegates to Service.DisableTOTP.

func (*Auth) FindUser added in v0.2.0

func (a *Auth) FindUser(ctx context.Context, userID, email, tenantID string) (User, error)

FindUser delegates to Service.FindUser.

func (*Auth) ForgotPassword added in v0.2.0

func (a *Auth) ForgotPassword(ctx context.Context, in ForgotPasswordInput) (string, error)

ForgotPassword delegates to Service.ForgotPassword and then throws away one class of failure: a send that failed after the token was stored. The route answers 200 {"success":true} for it, exactly as it does for an address that does not exist.

This is a deliberate divergence, and the only one on this route. The reference's send sits inside the route's try block, so a throwing mailer reaches handleError and answers 500 (auth.router.ts:787-798) — which means a known address 500s while an unknown one 200s, and an attacker with a broken mail gateway has the enumeration oracle the route exists to deny. The spec records exactly that and marks it [UNTESTED] (wire-contract §2, "Anti-enumeration caveat: a throwing mailer produces a 500 only for existing users, which is an observable oracle"), so no client can depend on it. The port already refuses to ship an oracle the reference leaves open elsewhere — SMSVerifyHTTPError answers 401 where the reference would answer 404 for the same reason.

Store failures are NOT swallowed: those still 500, and that oracle *is* reproduced (see ForgotPasswordHTTPError and the suite case that pins it). The difference is that a store that cannot persist a reset token has minted no credential and served no request correctly, while a stored-but-undelivered token is harmless — unguessable, single-use, expiring — so silence costs the caller nothing but a mail that never arrives.

Divergence from Service.ForgotPassword is the same arrangement as Auth.ChangePassword's: the Auth methods are the HTTP surface, and a library consumer calling the service directly still learns that delivery failed.

func (*Auth) IDP added in v0.6.0

func (a *Auth) IDP() *IDP

IDP reports the Identity Provider WithIDP configured, or nil. The adapters consult it to decide whether to mount the JWKS route and where, and to walk OIDCMounts for the four OIDC endpoints.

func (*Auth) IssueTempToken added in v0.2.0

func (a *Auth) IssueTempToken(ctx context.Context, user User) (string, error)

IssueTempToken delegates to Service.IssueTempToken.

func (*Auth) IssueTokensForUser added in v0.2.0

func (a *Auth) IssueTokensForUser(ctx context.Context, userID, tenantID string) (User, AuthTokens, error)

IssueTokensForUser delegates to Service.IssueTokensForUser.

func (*Auth) JWKSHandler added in v0.6.0

func (a *Auth) JWKSHandler() http.Handler

JWKSHandler returns the handler serving the IdP's JWKS document with the reference's headers, or nil when no WithIDP was given: the same handler the adapters mount at <prefix><IDPConfig.JWKSPath>. See (*IDP).JWKSHandler.

A host mounting it itself must keep it public and ahead of any middleware, as the reference does (auth.router.ts:473-474): a relying party fetches the document with no credential of any kind.

func (*Auth) LinkRequest added in v0.2.0

func (a *Auth) LinkRequest(ctx context.Context, in LinkRequestInput) (LinkRequestResult, error)

LinkRequest issues a single-use account-link token and hands the verification link to the configured delivery hook.

func (*Auth) LinkVerify added in v0.2.0

func (a *Auth) LinkVerify(ctx context.Context, in LinkVerifyInput) (LinkVerifyResult, error)

LinkVerify consumes an account-link token and writes the association.

func (*Auth) ListLinkedAccounts added in v0.2.0

func (a *Auth) ListLinkedAccounts(ctx context.Context, userID string) ([]PublicLinkedAccount, error)

ListLinkedAccounts returns a user's provider associations, always as a non-nil slice so the JSON is [] rather than null.

func (*Auth) ListSessions added in v0.2.0

func (a *Auth) ListSessions(ctx context.Context, userID, tenantID string) ([]Session, error)

ListSessions returns a user's *active* sessions, which is what the reference's GET /sessions serves: ISessionStore.getSessionsForUser is documented "Return all active sessions for the given user" (session-store.interface.ts:58-61), and because revokeSession is documented to "Invalidate (delete) a single session" (:70-76) — which the family's own store does literally, `async revokeSession(h) { this.sessions.delete(h); }` (ng-awesome-node-auth/src/server/in-memory-session-store.ts:29-31) — a revoked session is simply gone from the next list.

This port's MemorySessionStore tombstones instead of deleting, and correctly so: /refresh needs the tombstone to answer 401 SESSION_REVOKED rather than "not found". The filter therefore has to live above the store, and this is the accessor the routes go through.

Expired-but-not-revoked sessions are deliberately NOT filtered. The reference's store does not filter them out of getSessionsForUser either — only deleteExpiredSessions removes them — so a device-manager UI sees them there too.

func (*Auth) Login

func (a *Auth) Login(ctx context.Context, in LoginInput) (User, AuthTokens, error)

Login delegates to Service.Login.

func (*Auth) LoginWithChallenge added in v0.3.0

func (a *Auth) LoginWithChallenge(ctx context.Context, in LoginInput) (LoginResult, error)

LoginWithChallenge delegates to Service.LoginWithChallenge. It is what the adapters call, so that a 2FA-gated account is answered with the challenge rather than with the bare sentinel.

func (*Auth) Logout

func (a *Auth) Logout(ctx context.Context, refreshToken string) error

Logout delegates to Service.Logout.

func (*Auth) LogoutAccessToken added in v0.2.0

func (a *Auth) LogoutAccessToken(ctx context.Context, accessToken string) error

LogoutAccessToken revokes the session an access token belongs to. It is the revocation path a cookie client's logout takes, since the refresh cookie is out of scope for <prefix>/logout whenever it is path-scoped.

func (*Auth) LogoutRequest added in v0.2.0

func (a *Auth) LogoutRequest(ctx context.Context, r *http.Request)

LogoutRequest ends the session the caller presents, best effort, and is the single logout revocation path all four adapters call.

The order matters. A refresh token in the body or the refresh cookie revokes the whole session, so it is tried first. But the refresh cookie is scoped to <prefix>/refresh in every configuration whose name does not resolve to __Host- — a Domain, or a non-root cookie Path — and a browser therefore sends nothing at all to <prefix>/logout. That is why the reference logout reads the *access* token cookie (auth.router.ts:592) and revokes payload.sid: the access cookie is scoped to "/" and is always in scope. Without the fallback, logout answers 200 {"success":true}, clears the cookies, and leaves the refresh token and the session live server-side.

Every failure is swallowed: an absent or already-unusable credential must still leave the caller logged out rather than stranded with live cookies.

func (*Auth) Me

func (a *Auth) Me(ctx context.Context, accessToken string) (User, error)

Me delegates to Service.Me.

func (*Auth) OAuthBegin added in v0.2.0

func (a *Auth) OAuthBegin(ctx context.Context, in OAuthBeginInput) (OAuthBeginResult, error)

OAuthBegin mints a signed state, derives the PKCE challenge and builds the provider authorization URL.

func (*Auth) OAuthComplete added in v0.2.0

func (a *Auth) OAuthComplete(ctx context.Context, in OAuthCompleteInput) (OAuthCompleteResult, error)

OAuthComplete verifies the state, exchanges the code with the PKCE verifier and resolves the user.

func (*Auth) OAuthConfigured added in v0.2.0

func (a *Auth) OAuthConfigured() bool

OAuthConfigured reports whether the OAuth routes have anything to serve. The adapters mount the routes unconditionally, so they need this to answer the reference's "not configured" stub.

func (*Auth) Refresh

func (a *Auth) Refresh(ctx context.Context, refreshToken string) (AuthTokens, error)

Refresh delegates to Service.Refresh.

func (*Auth) Register

func (a *Auth) Register(ctx context.Context, in RegisterInput) (User, AuthTokens, error)

Register delegates to Service.Register.

func (*Auth) RequestEmailChange added in v0.2.0

func (a *Auth) RequestEmailChange(ctx context.Context, in ChangeEmailRequestInput) (string, error)

RequestEmailChange delegates to Service.RequestEmailChange.

func (*Auth) ResetPassword added in v0.2.0

func (a *Auth) ResetPassword(ctx context.Context, in ResetPasswordInput) error

ResetPassword delegates to Service.ResetPassword.

func (*Auth) ResolveHTTPConfig added in v0.2.0

func (a *Auth) ResolveHTTPConfig(cfg HTTPConfig) HTTPConfig

ResolveHTTPConfig fills cfg's derived defaults from this instance's token lifetimes. Adapters call it once at mount time.

func (*Auth) ResolveSiteURL added in v0.4.0

func (a *Auth) ResolveSiteURL(r *http.Request) string

ResolveSiteURL is the reference's resolveSiteUrl (auth.router.ts:233-246) for one request: the Origin header when it is allowlisted, else the origin of the Referer when that is, else the default site URL. The result is always either an allowlist entry or the default, which is what keeps a caller from steering a link — or an OAuth redirect — at an origin of its choosing.

The allowlist is buildAllowedOrigins (auth.router.ts:213-219): Config.SiteURLs followed by OAuthWiring.AllowedOrigins — the port's spelling of the reference's cors.origins for this purpose — deduplicated with the first occurrence's position kept. With an empty allowlist the headers are not consulted at all and the default is returned.

The default is the first Config.SiteURLs entry (getDefaultSiteUrl, auth.router.ts:202-206), unless OAuthWiring.SiteURL is set: that field predates SiteURLs, is documented as the same first siteUrl entry, and stays the override so that a deployment configured through it keeps its links and redirects. With neither the default is "".

It shares resolveSiteURL with OAuthBegin and LinkRequest, so an emailed link and an OAuth redirect resolve identically for the same request.

func (*Auth) RevokeUserSession added in v0.2.0

func (a *Auth) RevokeUserSession(ctx context.Context, userID, tenantID, sessionID string) error

RevokeUserSession delegates to Service.RevokeUserSession.

func (*Auth) SMSConfigured added in v0.3.0

func (a *Auth) SMSConfigured() bool

SMSConfigured reports whether the deployment can deliver an SMS code.

/sms/send has to ask before it does anything else, because the reference checks config.sms at the very top of the route (auth.router.ts:1178-1181) and not in the strategy behind it. The ordering is client-visible, and not in a cosmetic way: the route answers 200 to an unknown address as an anti-enumeration measure, so a handler that only found out at the service call would tell an unconfigured deployment's caller "sent" for every address it does not have. The magic-link route needs no equivalent — the reference checks email configuration inside the strategy there, which Service.SendMagicLink reproduces, and WriteServiceError maps the sentinel.

func (a *Auth) SendMagicLink(ctx context.Context, in MagicLinkSendInput) (string, error)

SendMagicLink delegates to Service.SendMagicLink.

func (*Auth) SendSMSCode added in v0.2.0

func (a *Auth) SendSMSCode(ctx context.Context, in SMSCodeSendInput) (string, error)

SendSMSCode delegates to Service.SendSMSCode.

func (*Auth) SendVerificationEmailToken added in v0.2.0

func (a *Auth) SendVerificationEmailToken(ctx context.Context, in EmailVerificationInput) (string, error)

SendVerificationEmailToken delegates to Service.SendVerificationEmailToken. An empty token with no error means the address is already verified.

func (*Auth) Service

func (a *Auth) Service() *Service

Service exposes the configured core service.

func (*Auth) StartTOTPEnrolment added in v0.2.0

func (a *Auth) StartTOTPEnrolment(ctx context.Context, userID, tenantID string) (TOTPSetup, error)

StartTOTPEnrolment delegates to Service.StartTOTPEnrolment.

func (*Auth) StepUpSubject added in v0.2.0

func (a *Auth) StepUpSubject(tempToken string, missing, invalid HTTPError) (TempTokenSubject, HTTPError, bool)

StepUpSubject resolves the tempToken a 2FA-mode request carries, reporting the envelope to write when it cannot.

The two error arguments are what keeps the reference's inconsistency reproducible without spreading it through the handlers: the magic-link and SMS routes pass HTTPErrTempTokenRequired and HTTPErrInvalidTempToken, while /2fa/verify passes HTTPErrInvalidStepUpToken for both — it has no missing-token branch at all, so an absent tempToken simply fails verification there.

func (*Auth) TwoFactorPolicy added in v0.2.0

func (a *Auth) TwoFactorPolicy(ctx context.Context) (bool, error)

TwoFactorPolicy reports whether the deployment requires a second factor of every user, which is what POST <prefix>/2fa/disable refuses on with 403 2FA_REQUIRED. It is the OR of two terms:

  • Config.Require2FA, the static deployment-time switch. It is also a login term (Service.requiresTwoFactor), and it is this port's own: the reference has no config-level require2FA anywhere. That is a wire difference, and it is registered as config-require2fa-is-a-system-policy-term in compatibility.go rather than stated only here.
  • AuthSettings.Require2FA read from Config.Settings, the run-time switch an administrator flips through the admin Control panel. This is the reference's own term and the only setting it reads on this route (auth.router.ts:890-896).

The static term is checked first and short-circuits, so a deployment that has already decided the answer never pays for a store read.

The error is the store failing. It is reported rather than swallowed so that the route can fail closed — a settings store that cannot be reached must not be read as permission to turn the factor off — and the adapters answer the reference generic 500, which is what its handleError does with anything its getSettings throws (auth.router.ts:189-195, the catch at :899-901).

It is also logged through Config.Logger before being returned, because the 500 the adapters write carries no detail at all — the reference logs the error itself before answering the same body (console.error in handleError, :193) — and a policy-store outage that leaves nothing in the operator's log is indistinguishable from any other 500. A caller that handles the error itself gets one log line it did not ask for, which is the cheaper of the two mistakes.

The signature grew the context and the error when the settings store landed. It was TwoFactorPolicy() bool while the answer came from Config alone, when there was nothing to read and nothing to fail; Go has no overloading, so the old spelling could only be kept under a different name that nothing would call, and it was dropped instead. The CHANGELOG records the break.

func (*Auth) UIConfig added in v0.7.0

func (a *Auth) UIConfig(ctx context.Context, r *http.Request, cfg HTTPConfig) UIConfig

UIConfig builds the document GET <prefix>/ui/config serves (ui.router.ts: 95-170). The adapters call it; a host serving its own UI route may call it too.

r supplies the lang query parameter and nothing else, and may be nil. cfg is the wire configuration the adapter was mounted with: its Prefix is the document's apiPrefix and its UI block is the branding, the language default and the headless flag.

There is no error return, because the route has no error path: a settings store or template store that fails is caught and answered with the fallback document (:143-161), exactly as the reference's catch does. A store failure is therefore invisible to the client beyond the shorter features object, which is why it is logged through Config.Logger on the way past.

func (*Auth) UnlinkAccount added in v0.2.0

func (a *Auth) UnlinkAccount(ctx context.Context, userID, provider, providerAccountID string) error

UnlinkAccount removes one provider association.

The reference performs no existence check and answers success unconditionally (wire-contract.md §4, DELETE /linked-accounts). This keeps that, and adds the ownership check the reference gets for free by passing the user id into its store: a link that belongs to somebody else is left alone.

func (*Auth) UpdatePhoneNumber added in v0.2.0

func (a *Auth) UpdatePhoneNumber(ctx context.Context, in AddPhoneInput) (User, error)

UpdatePhoneNumber delegates to Service.UpdatePhoneNumber.

func (*Auth) UpdateProfile

func (a *Auth) UpdateProfile(ctx context.Context, in UpdateProfileInput) (User, error)

UpdateProfile delegates to Service.UpdateProfile.

func (*Auth) VerifyEmail added in v0.2.0

func (a *Auth) VerifyEmail(ctx context.Context, in VerifyEmailInput) error

VerifyEmail delegates to Service.VerifyEmail.

func (a *Auth) VerifyMagicLink(ctx context.Context, in MagicLinkVerifyInput) (User, AuthTokens, error)

VerifyMagicLink delegates to Service.VerifyMagicLink.

func (*Auth) VerifyMagicLinkForUser added in v0.2.0

func (a *Auth) VerifyMagicLinkForUser(ctx context.Context, in MagicLinkVerifyInput, userID string) (User, AuthTokens, error)

VerifyMagicLinkForUser delegates to Service.VerifyMagicLinkForUser. The step-up branch of POST /magic-link/verify uses it so that a link belonging to another user is refused before a session exists.

func (*Auth) VerifySMSCode added in v0.2.0

func (a *Auth) VerifySMSCode(ctx context.Context, in SMSCodeVerifyInput) (User, AuthTokens, error)

VerifySMSCode delegates to Service.VerifySMSCode.

func (*Auth) VerifyTOTP added in v0.2.0

func (a *Auth) VerifyTOTP(ctx context.Context, userID, tenantID, code string) (User, AuthTokens, error)

VerifyTOTP delegates to Service.VerifyTOTP.

func (*Auth) VerifyTOTPSetup added in v0.2.0

func (a *Auth) VerifyTOTPSetup(ctx context.Context, userID, tenantID, secret, code string) error

VerifyTOTPSetup delegates to Service.VerifyTOTPSetup.

type AuthCode added in v0.4.0

type AuthCode struct {
	CodeHash            string
	UserID              string
	TenantID            string
	ClientID            string
	Nonce               string
	RedirectURI         string
	CodeChallenge       string
	CodeChallengeMethod string
	Scope               string
	ExpiresAt           time.Time
}

AuthCode is one OIDC authorization code as the IDP stores it between /authorize and /token. CodeHash is hashToken of the value the client was given; the clear-text code is never persisted. RedirectURI is the canonical entry from IDPClient.RedirectURIs that the authorization request matched. CodeChallenge, CodeChallengeMethod and Scope are recorded from the authorization request as plain data: this package does not verify them yet, but carrying them now means a store written today needs no schema change when /token starts to.

type AuthCodeStore added in v0.4.0

type AuthCodeStore interface {
	SaveCode(ctx context.Context, code AuthCode) error
	ConsumeCode(ctx context.Context, codeHash string) (AuthCode, error)
}

AuthCodeStore persists OIDC authorization codes for the IDP.

Design note. The IDP endpoints are net-new relative to the TypeScript reference, which ships no authorization server, so there is no wire contract to cite; the shape below is chosen for the runtimes this library targets.

  • The store is keyed by hashToken(code), never by the code itself. The client carries a 24-byte random token in the redirect; the store sees only its SHA-256, so a dump of the backing table cannot be redeemed at /token. This matches how refresh tokens, reset tokens and magic links are already held.
  • ConsumeCode is the only read and it is destructive: it returns a record exactly once and answers ErrInvalidCode for every later call with the same hash, including concurrent ones. That is the single-use guarantee /token relies on. It used to come from a process-local map, which holds only while /authorize and /token run in the same process; a serverless or multi-instance deployment must supply a shared implementation via IDPConfig.Codes, and its ConsumeCode must be atomic (a conditional delete, a row lock, or a DEL-and-check pipeline, not a read followed by a delete).
  • An expired record is absent: ConsumeCode returns ErrInvalidCode for it and is free to delete it. Implementations may also sweep expired rows on SaveCode or on a schedule. The handler re-checks ExpiresAt regardless.
  • SaveCode overwrites an existing record with the same CodeHash. The codes are random, so in practice this never happens; the rule exists so an implementation need not detect the collision.
  • Errors other than ErrInvalidCode are surfaced by /authorize and /token as plain 500s; the store is not expected to map them.

type AuthSettings added in v0.6.0

type AuthSettings struct {
	// RequireEmailVerification is the legacy boolean form of
	// EmailVerificationMode (settings-store.interface.ts:47). Stored only: see
	// the file comment, and Config.EmailVerificationMode for the knob that does
	// decide a login.
	RequireEmailVerification *bool `json:"requireEmailVerification,omitempty"`
	// EmailVerificationMode is none, lazy or strict
	// (settings-store.interface.ts:55), and overrides
	// RequireEmailVerification when both are set. Stored only.
	EmailVerificationMode *string `json:"emailVerificationMode,omitempty"`
	// LazyEmailVerificationGracePeriodDays is the lazy-mode grace period in days
	// (settings-store.interface.ts:62), 7 when unset. Stored only; the reference
	// admin UI displays it and nothing enforces it. Note the naming drift in the
	// reference itself: its admin OpenAPI calls the same key
	// emailVerificationGracePeriodDays (openapi.ts:1278). The interface name is
	// the one used here, because it is the one the admin UI writes.
	LazyEmailVerificationGracePeriodDays *int `json:"lazyEmailVerificationGracePeriodDays,omitempty"`
	// Require2FA is the one setting this library acts on: when true,
	// POST <prefix>/2fa/disable answers 403 2FA_REQUIRED
	// (auth.router.ts:890-896). Auth.TwoFactorPolicy is where it is read.
	Require2FA *bool `json:"require2FA,omitempty"`
	// EnabledWebhookActions is the global allowlist of inbound-webhook action
	// ids (settings-store.interface.ts:74), the circuit breaker above each
	// webhook own allowedActions list. Stored only until the tools router lands.
	//
	// nil and empty are different values here, as they are everywhere else in
	// this type: nil is absent and keeps what is stored, an empty non-nil slice
	// is the administrator switching every action off. MarshalJSON is what keeps
	// that difference alive through an encoder.
	EnabledWebhookActions []string `json:"enabledWebhookActions,omitempty"`
	// UI is the branding block. It is one field of the patch, so a patch that
	// carries it replaces the stored block whole rather than merging into it:
	// see MergeSettings.
	UI *UISettings `json:"ui,omitempty"`
}

AuthSettings is the reference's AuthSettings (settings-store.interface.ts:45-92), in both roles it plays there: the whole stored value that GetSettings returns, and the Partial<AuthSettings> patch that UpdateSettings applies. Every field is optional in the reference, so every field here is a pointer or a slice, and a nil one means absent — which on the way in means keep what is stored, and on the way out means the store never held it.

The JSON names are the reference's, so a stored document round-trips between this port and a node-auth deployment unchanged. Round-tripping the *cleared* state of enabledWebhookActions needs one correction that struct tags cannot express on their own: see MarshalJSON.

func MergeSettings added in v0.6.0

func MergeSettings(current, patch AuthSettings) AuthSettings

MergeSettings applies patch over current and returns the result, the shallow spread the reference's ISettingsStore.updateSettings contract requires: merged = {...current, ...settings} (settings-store.interface.ts:20-24 is the JSDoc example, :36-38 the interface comment, "Settings not present in the input are left untouched").

The contract is all the reference's src/ carries: it ships no ISettingsStore implementation there (src/stores/ holds only memory-template.store.ts). The implementations live beside it and all spread the same way — the closest to this one is examples/in-memory-user-store.ts:266-268, this.settings = { ...this.settings, ...updates }.

Shallow is the operative word, and the one place it bites is UI. A patch whose UI is nil keeps the stored branding; a patch that carries a UI replaces the stored branding whole, so the seven fields it does not name are dropped, not preserved. That is what the spread does, and the reference depends on it: its admin PATCH route reads the current settings, merges the UI sub-object itself and sends the merged block back down (admin.router.ts:979-981), which it would have no reason to do if the store merged it. A caller that wants to change one colour does the same — read, merge, write.

EnabledWebhookActions follows the same rule as a whole value: nil keeps, and a non-nil slice replaces, an empty one included. Everything the result points at is freshly allocated, so neither input can be mutated through it.

func (AuthSettings) MarshalJSON added in v0.6.0

func (s AuthSettings) MarshalJSON() ([]byte, error)

MarshalJSON encodes the settings under the reference's names, with one correction encoding/json cannot make from a struct tag.

omitempty on a []string drops an empty slice as well as a nil one, so a plain tag would encode AuthSettings{EnabledWebhookActions: []string{}} as {} — and the cleared list, which is how an administrator switches every inbound-webhook action off, would come back from a store that persists JSON as absent, which MergeSettings reads as keep. The list would quietly come back on. The reference has no such hole: JSON.stringify({enabledWebhookActions: []}) emits [].

So the field is encoded through a pointer, where omitempty means nil and nothing else: nil stays absent, and a non-nil empty slice is written as []. Decoding needs no counterpart, because encoding/json already decodes [] into a non-nil empty slice.

type AuthTokens

type AuthTokens struct {
	AccessToken  string
	RefreshToken string
	ExpiresIn    time.Duration
}

AuthTokens are the issued access/refresh pair.

type CSRFConfig

type CSRFConfig struct {
	// Enabled gates both enforcement and cookie distribution, mirroring the
	// reference's config.csrf.enabled.
	Enabled    bool
	CookieName string
	HeaderName string
}

CSRFConfig configures the cookie+header double-submit CSRF protection. The cookie attributes come from HTTPConfig.Cookies so that the CSRF cookie is serialised by the same matrix as the token cookies.

func DefaultCSRFConfig

func DefaultCSRFConfig() CSRFConfig

DefaultCSRFConfig returns defaults compatible with browser cookie auth flows.

type ChangeEmailRequestInput

type ChangeEmailRequestInput struct {
	UserID   string
	TenantID string
	NewEmail string
	// LinkBase and Lang are what ForgotPasswordInput's are, for
	// EmailChangeDelivery (auth.router.ts:1026-1028).
	LinkBase string
	Lang     string
}

type ChangePasswordInput

type ChangePasswordInput struct {
	UserID          string
	TenantID        string
	CurrentPassword string
	NewPassword     string
}

type ClaimsWebhook added in v0.5.0

type ClaimsWebhook struct {
	// URL is the absolute http(s) address every request goes to.
	URL string
	// Secret keys the signature. Empty sends no X-Webhook-Signature header at
	// all, as the reference's sender does without one (webhook-sender.ts:31-33).
	// It is never sent and never appears in an error.
	Secret string
	// Timeout bounds one request, connect to last byte, through the request
	// context — a shorter deadline already on ctx wins. Zero or negative means
	// 2 seconds: a login is waiting on the answer.
	Timeout time.Duration
	// Client issues the requests. Nil means http.DefaultClient. A Client with a
	// Timeout of its own is bounded by both.
	Client *http.Client
}

ClaimsWebhook is a TokenClaimsBuilder that asks an HTTP endpoint for the claims: the consumer's security.jwt.claimsWebhook {url, timeoutMs} knob (config-schema.md §1.1, §3.1), the escape hatch for claims that are computed rather than mapped. It is this port's own extension, like the helpers in claims.go: the reference's only hook is the in-process config.buildTokenPayload(user) (auth.router.ts:378-384), which never crosses HTTP, so the request and response below have no reference wire to cite. The envelope is the family's outbound-webhook convention — the four X-Webhook-* headers and the "sha256=<hex>" HMAC form of the reference's WebhookSender (src/tools/webhook-sender.ts:24-33, :54-56) — so a receiver written for the tools router's webhooks verifies these the same way.

Every mint — login, refresh, the 2FA step-up token — and every GET /me POSTs one request to URL with Content-Type: application/json, the body

{"user": <the user as GET /me renders it — PublicUser, credentials omitted>}

and the headers

X-Webhook-Event:     claims.build
X-Webhook-Delivery:  <a fresh UUID per request>
X-Webhook-Timestamp: <ISO 8601 UTC with milliseconds>
X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the body>   (only when Secret is set)

and expects a 2xx answer whose JSON body is

{"claims": {<claim name>: <value>, ...}}

The claims object becomes the builder's result and is merged as any hook's is: it may override the six base claims and cannot set the reserved session claims — issueToken writes sid, tid, jti, typ, iss, iat and exp after the merge — so an endpoint that is compromised, or merely confused, cannot retype a token or rebind its session. Anything else is an error: a non-2xx status, a transport failure, the timeout, a body over 64 KiB, a body that is not JSON, a missing or null "claims" member or one that is not an object. An error fails the mint, and the route answers 500 exactly as it does for any failing Config.BuildTokenClaims (token.go). Failing closed is the point: a token minted without the claims the deployment configured would authorise less, or more, than the deployment decided.

A builder runs on every mint and on GET /me, and only there: the adapters' Middleware authenticates through Service.Authenticate, which never calls it. One login is therefore one request here, and a protected route is none.

The zero value is usable once URL is set; NewClaimsWebhook additionally validates the URL and fills in the default timeout. A ClaimsWebhook is safe for concurrent use once its fields stop changing.

func NewClaimsWebhook added in v0.5.0

func NewClaimsWebhook(rawURL, secret string) (*ClaimsWebhook, error)

NewClaimsWebhook builds a ClaimsWebhook for an absolute http(s) URL with the default 2 second timeout. secret may be empty, in which case requests are unsigned.

func (*ClaimsWebhook) Build added in v0.5.0

func (w *ClaimsWebhook) Build(ctx context.Context, user User) (map[string]any, error)

Build posts user to the endpoint and returns the claims it answers with. Its signature is TokenClaimsBuilder's, so a *ClaimsWebhook is wired with WithTokenClaimsBuilder(hook.Build) or as one link of a ChainClaims.

An error carries the status or the transport failure and nothing from the response body, which an endpoint may well fill with an echo of the request.

type Config

type Config struct {
	Secret          string
	Issuer          string
	AccessTokenTTL  time.Duration
	RefreshTokenTTL time.Duration
	SessionCheckOn  string
	ClockSkew       time.Duration
	MinPasswordLen  int
	// BcryptCost is the bcrypt cost used for every password hash the library
	// writes. It is this port's spelling of the reference's
	// AuthConfig.bcryptSaltRounds; "cost" is what x/crypto/bcrypt calls the
	// parameter, and what bcrypt.DefaultCost, MinCost and MaxCost name.
	//
	// Zero means unset, and unset means bcrypt.DefaultCost — never
	// bcrypt.MinCost. A Config that was never told a cost must not silently get
	// the weakest one. Any other value must lie in
	// [bcrypt.MinCost, bcrypt.MaxCost]; validate rejects the rest.
	BcryptCost            int
	ResetTokenTTL         time.Duration
	MagicLinkTTL          time.Duration
	SMSCodeTTL            time.Duration
	EmailVerificationTTL  time.Duration
	EmailVerificationMode string
	EmailChangeTTL        time.Duration
	TempTokenTTL          time.Duration
	Require2FA            bool
	// TwoFactorAppName is the issuer an authenticator app files a TOTP
	// enrolment under: the `issuer` of the otpauth:// URI POST <prefix>/2fa/setup
	// returns, and the prefix of its label. It is the reference's
	// `twoFactor.appName` (auth-config.model.ts:272-274, read at
	// auth.router.ts:830).
	//
	// Empty means unset, and unset means Config.Issuer — which is what the URI
	// carried before this field existed, so enrolments made then and enrolments
	// made now sit under the same name in users' apps. The reference falls back
	// to the literal 'awesome-node-auth' instead; that difference is registered
	// as the deviation totp-issuer-defaults-to-config-issuer. WithTwoFactorAppName
	// sets it.
	TwoFactorAppName string
	// BuildTokenClaims adds claims to every token minted — access, refresh and
	// the 2FA step-up token — and fills CustomClaims on the profile Me returns.
	// Its result is spread over the six base claims (sub, email, role,
	// loginProvider, isEmailVerified, isTotpEnabled) and may override them, as
	// the reference's buildTokenPayload may (auth.router.ts:378-384). The
	// session claims sid, tid, jti, typ, iss, iat and exp are reserved: they
	// are written after the merge, so a hook value under one of those names is
	// discarded rather than minted (see issueToken for why).
	//
	// It runs at mint time and on Me, and nowhere else: Authenticate, which the
	// adapters' Middleware calls on every protected request, never runs it. A
	// hook that cannot answer fails the mint (the route answers 500) and is
	// merely logged on Me. StaticClaims, UserFieldClaims, ChainClaims and
	// ClaimsWebhook build one from configuration rather than code.
	BuildTokenClaims TokenClaimsBuilder
	// PasswordVerifier is the migration seam: the hook POST <prefix>/login
	// consults for a user whose stored hash did not verify the supplied
	// password, so that a deployment moving off another identity provider can
	// accept that provider's password on the first login, adopt it as a local
	// bcrypt hash and stop calling the old system.
	//
	// It is an additive port extension with no counterpart in the reference,
	// whose login path verifies bcrypt directly and offers no hook
	// (local.strategy.ts:19-29). Nil, the default, is the reference's behaviour
	// exactly: the verifier is never called, and every login answers as it did
	// before this field existed. WithPasswordVerifier sets it. See
	// password_verifier.go.
	PasswordVerifier PasswordVerifier
	// SendMagicLink and SendSMSCode are the delivery seam. Both are optional to
	// construct a service with and required to use the route that needs them:
	// leaving one nil is what makes POST <prefix>/magic-link/send answer 500
	// EMAIL_NOT_CONFIGURED and POST <prefix>/sms/send answer 500
	// SMS_NOT_CONFIGURED. See delivery.go for why they live here.
	SendMagicLink MagicLinkSender
	SendSMSCode   SMSCodeSender
	// SendPasswordReset, SendEmailVerification and SendEmailChange are the other
	// half of the same seam, for POST <prefix>/forgot-password,
	// POST <prefix>/send-verification-email and POST <prefix>/change-email/request.
	//
	// Leaving one nil is silence rather than an error: the reference's routes send
	// no mail and still answer 200 when neither a callback nor a mailer is
	// configured, so these three have no NOT_CONFIGURED code. See
	// delivery_password_email.go.
	SendPasswordReset     PasswordResetSender
	SendEmailVerification EmailVerificationSender
	SendEmailChange       EmailChangeSender
	// SendEmailChanged is the reference's config.email.sendEmailChanged
	// (auth-config.model.ts:252-257): the notice POST <prefix>/change-email/confirm
	// mails to the OLD address once the change is applied (auth.router.ts:
	// 1060-1066). Optional; nil is silence, as for the three above. A failure is
	// the reference's generic 500 with the change already committed — see
	// Service.ConfirmEmailChange.
	SendEmailChanged EmailChangedSender
	// Templates is the reference's config.templateStore
	// (auth-config.model.ts:400, read by mailer.service.ts:146): a TemplateStore whose
	// templates override the built-ins for every ready-made mailer the service
	// calls, and which will hold the UI translations once the UI router reads
	// them. Optional; nil renders the built-ins. WithTemplateStore sets it.
	Templates TemplateStore
	// Settings is the reference's routerOptions.settingsStore
	// (auth.router.ts:92, read at :890-896): the global switches an
	// administrator flips at run time. Optional; nil means the settings check is
	// skipped altogether, as it is in the reference without a store.
	//
	// One setting reaches this library: AuthSettings.Require2FA, which
	// Auth.TwoFactorPolicy ORs with Require2FA above and
	// POST <prefix>/2fa/disable refuses on. The email-verification settings are
	// stored and are deliberately not consulted at login, because the reference
	// does not consult them either — see settings_store.go and README_DETAILED,
	// "Runtime settings". WithSettingsStore sets it.
	Settings SettingsStore
	// SiteURLs is the reference's config.email.siteUrl, which may be one string
	// or an array of them (auth-config.model.ts). Two things are read off it:
	//
	//   - The first entry is the canonical base URL for an emailed link
	//     (getDefaultSiteUrl, auth.router.ts:202-206): what a link points at when
	//     the request that asked for it carries no allowlisted Origin or Referer.
	//   - Every entry, together with OAuthWiring.AllowedOrigins, forms the origin
	//     allowlist Auth.ResolveSiteURL matches a request against
	//     (buildAllowedOrigins, auth.router.ts:213-219), so a deployment serving
	//     several front ends mails each one a link back to itself.
	//
	// Entries are matched exactly, as the reference's `includes` does: no trailing
	// slash normalisation, no case folding. Optional; with none configured the
	// adapters hand the ready-made mailers no per-request base and their static
	// BaseURL applies, as it did before this field existed. WithSiteURLs sets it.
	SiteURLs []string
	Logger   func(format string, args ...any)
}

Config contains security and token settings for the auth service.

func DefaultConfig

func DefaultConfig(secret string) Config

DefaultConfig returns secure defaults for development and production bootstrap.

type ConfirmEmailChangeInput

type ConfirmEmailChangeInput struct {
	Token string
	// Lang is copied onto EmailChangedDelivery for the notice to the old
	// address. The adapters leave it empty: the reference's route reads only
	// token from the body and calls sendEmailChanged without a language
	// (auth.router.ts:1046, 1062, 1065), so the notice renders in the mailer's
	// default. It is here for a caller driving the service directly.
	Lang string
}

type CookieOptions added in v0.2.0

type CookieOptions struct {
	Secure   bool
	SameSite http.SameSite
	Path     string
	Domain   string
	// RefreshTokenPath scopes the refresh cookie. Empty resolves to
	// <APIPrefix>/refresh, matching the reference's router-time default.
	RefreshTokenPath   string
	AccessTokenMaxAge  time.Duration
	RefreshTokenMaxAge time.Duration
	CSRFTokenMaxAge    time.Duration
}

CookieOptions is the cookie serialisation matrix. Every adapter writes cookies through it, so the attributes cannot diverge between frameworks.

func DefaultCookieOptions added in v0.2.0

func DefaultCookieOptions() CookieOptions

DefaultCookieOptions returns the port's defaults. Secure defaults to true — stricter than the reference's false — because every cookie here is either a credential or a CSRF token.

func (CookieOptions) ClearAuthCookies added in v0.2.0

func (o CookieOptions) ClearAuthCookies(w http.ResponseWriter, includeCSRF bool)

ClearAuthCookies expires every name variant of the auth cookies. Expiring a cookie requires an exact name+path+domain match, so a cookie written under a different prefix — by an older release, or before Secure was switched on — would otherwise survive logout.

func (CookieOptions) CookieName added in v0.2.0

func (o CookieOptions) CookieName(base string) string

CookieName resolves the prefix a cookie must carry: an insecure cookie stays bare, a secure cookie that is host-scoped to "/" earns __Host-, and any other secure cookie earns __Secure-. The refresh cookie is resolved from Path, not RefreshTokenPath, exactly as the reference does.

func (CookieOptions) SetAccessTokenCookie added in v0.2.0

func (o CookieOptions) SetAccessTokenCookie(w http.ResponseWriter, value string)

SetAccessTokenCookie writes the access-token cookie.

func (CookieOptions) SetCSRFTokenCookie added in v0.2.0

func (o CookieOptions) SetCSRFTokenCookie(w http.ResponseWriter, value string)

SetCSRFTokenCookie writes the CSRF cookie. It is deliberately not HttpOnly: double-submit requires the page to read it and mirror it into the header.

func (CookieOptions) SetRefreshTokenCookie added in v0.2.0

func (o CookieOptions) SetRefreshTokenCookie(w http.ResponseWriter, value string)

SetRefreshTokenCookie writes the refresh-token cookie.

type DeleteAccountInput

type DeleteAccountInput struct {
	UserID   string
	TenantID string
}

type DeliveryWebhook added in v0.4.0

type DeliveryWebhook struct {
	// URL is the absolute http(s) address every request goes to.
	URL string
	// Secret keys the signature. Empty sends no X-Webhook-Signature header at
	// all, as the reference's sender does without one (webhook-sender.ts:31-33).
	// It is never sent.
	Secret string
	// Timeout bounds one request, connect to last byte, through the request
	// context — a shorter deadline already on ctx wins. Zero or negative means
	// 5 seconds.
	Timeout time.Duration
	// Client issues the requests. Nil means http.DefaultClient. A Client with a
	// Timeout of its own is bounded by both.
	Client *http.Client
}

DeliveryWebhook is a sender for every delivery kind at once. Its five Send methods have the five sender signatures, so one value serves every seam:

hook, err := auth.NewDeliveryWebhook("https://hooks.example.com/auth-delivery", secret)
a, err := auth.New(
	auth.WithMagicLinkSender(hook.SendMagicLink),
	auth.WithPasswordResetSender(hook.SendPasswordReset),
	auth.WithEmailVerificationSender(hook.SendEmailVerification),
	auth.WithEmailChangeSender(hook.SendEmailChange),
	auth.WithSMSCodeSender(hook.SendSMSCode),
)

Each call POSTs one request to URL with Content-Type: application/json, the body {"kind": "<kind>", "delivery": {…}} (DeliveryWebhookRequest), and the headers

X-Webhook-Event:     delivery.<kind>
X-Webhook-Delivery:  <a fresh UUID per request>
X-Webhook-Timestamp: <ISO 8601 UTC with milliseconds>
X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the body>   (only when Secret is set)

Any 2xx answer is delivered. Anything else, a transport failure or the timeout is an error, which the route the sender is wired to handles per its existing contract. The error names the kind, the status and, for mail, the recipient's domain; it never carries the token, the code, the full address or the secret, because an error travels further than the request does — it is logged, and it is host code's to inspect.

The zero value is usable once URL is set; NewDeliveryWebhook additionally validates the URL and fills in the default timeout.

func NewDeliveryWebhook added in v0.4.0

func NewDeliveryWebhook(rawURL, secret string) (*DeliveryWebhook, error)

NewDeliveryWebhook builds a DeliveryWebhook for an absolute http(s) URL with the default 5 second timeout. secret may be empty, in which case requests are unsigned.

func (*DeliveryWebhook) SendEmailChange added in v0.4.0

func (w *DeliveryWebhook) SendEmailChange(ctx context.Context, delivery EmailChangeDelivery) error

SendEmailChange posts an email-change delivery, addressed to the new mailbox. Its signature is EmailChangeSender's.

func (*DeliveryWebhook) SendEmailVerification added in v0.4.0

func (w *DeliveryWebhook) SendEmailVerification(ctx context.Context, delivery EmailVerificationDelivery) error

SendEmailVerification posts an email-verification delivery. Its signature is EmailVerificationSender's.

func (w *DeliveryWebhook) SendMagicLink(ctx context.Context, delivery MagicLinkDelivery) error

SendMagicLink posts a magic-link delivery. Its signature is MagicLinkSender's.

func (*DeliveryWebhook) SendPasswordReset added in v0.4.0

func (w *DeliveryWebhook) SendPasswordReset(ctx context.Context, delivery PasswordResetDelivery) error

SendPasswordReset posts a password-reset delivery. Its signature is PasswordResetSender's.

func (*DeliveryWebhook) SendSMSCode added in v0.4.0

func (w *DeliveryWebhook) SendSMSCode(ctx context.Context, delivery SMSCodeDelivery) error

SendSMSCode posts an SMS-code delivery. Its signature is SMSCodeSender's.

type DeliveryWebhookRequest added in v0.4.0

type DeliveryWebhookRequest struct {
	Kind     string          `json:"kind"`
	Delivery json.RawMessage `json:"delivery"`
}

DeliveryWebhookRequest is the body DeliveryWebhook posts. Delivery is the delivery struct that matches Kind — MagicLinkDelivery for DeliveryKindMagicLink, PasswordResetDelivery for DeliveryKindPasswordReset, EmailVerificationDelivery, EmailChangeDelivery, SMSCodeDelivery — encoded with its JSON tags, so a receiver written in Go decodes Kind, switches on it and unmarshals Delivery into the very type this package sent.

type Deviation added in v0.3.0

type Deviation struct {
	// ID is the stable handle for this deviation. It never changes once
	// published: the README keys its mirror on it and the tests pin the set, so
	// renaming one is a deliberate, visible act rather than a silent edit.
	ID string `json:"id"`
	// Title is the one-line heading a human reads first. It becomes the README
	// section heading for this entry.
	Title string `json:"title"`
	// Surface is the route or API surface affected, written as it is mounted —
	// paths are relative to the configured prefix, spelled <prefix> here.
	Surface string `json:"surface"`
	// Behaviour is what this port does.
	Behaviour string `json:"behaviour"`
	// Reference is what awesome-node-auth does instead.
	Reference string `json:"reference"`
	// Citations locate Reference in the reference source, as file:line or
	// file:line-line, resolved against ReferenceRevision.
	Citations []string `json:"citations"`
	// Why is the reason the standing rule was set aside. An entry without one is
	// not a decision, it is a bug that has not been recognised yet.
	Why string `json:"why"`
	// Notes carries any further labelled paragraphs this entry needs beyond the
	// four above — how to observe the difference, what a host can do about it.
	// Most entries need none.
	Notes []DeviationNote `json:"notes,omitempty"`
}

Deviation is one place this port deliberately answers differently from the reference.

The two behaviours are recorded separately, and the reference's carries a citation, so that a reader can check the claim rather than take it — and so that a maintainer who changes one of them has to come here and say so.

type DeviationNote added in v0.3.0

type DeviationNote struct {
	Label string `json:"label"`
	Text  string `json:"text"`
}

DeviationNote is one extra labelled paragraph on a Deviation.

type DocsOptions added in v0.7.0

type DocsOptions struct {
	// Enabled mounts GET <prefix>/openapi.json and GET <prefix>/docs, neither
	// of them guarded, as the reference registers them
	// (auth.router.ts:1656-1677). Unset — the default — neither route is
	// registered at all, and both answer 404.
	//
	// Enabling the second of the two puts a remote script on the auth origin:
	// the page is the reference's, and it loads swagger-ui-dist@5 from the
	// unpkg CDN with no subresource integrity (see swaggerUIHTML). Whatever
	// unpkg serves then runs same-origin with the auth cookies, where the CSRF
	// cookie is readable from JavaScript by design. Keep the routes off in
	// production, or serve them behind a Content-Security-Policy that pins that
	// CDN — the deviation register's docs-routes-are-opt-in note says the same.
	//
	// It is a plain bool. The reference's option is boolean or the string
	// "auto", "auto" is the default, and it means enabled unless the process
	// environment says NODE_ENV=production (auth.router.ts:123-131, :1652-1654).
	// This port resolves nothing against an environment: a library whose routes
	// appear and disappear with a variable it never sees configured is a
	// library nobody can reason about from its own configuration, and Go has no
	// NODE_ENV to read anyway. Deciding what the ambient environment means is
	// the host's call, and it is one line where the HTTPConfig is built:
	//
	//	cfg.Docs.Enabled = os.Getenv("APP_ENV") != "production"
	//
	// The consequence — off by default where the reference is on by default
	// outside production — is the docs-routes-are-opt-in deviation.
	Enabled bool
	// BasePath is the prefix the served document writes its paths under, and
	// the base of the spec URL the Swagger UI page fetches. Empty means the
	// mount prefix, HTTPConfig.Prefix(), and is normalised the way that is, so
	// a base path and a mount built from the same string agree.
	//
	// It is the reference's swaggerBasePath (auth.router.ts:133-139, read at
	// :1657), which defaults to the configured apiPrefix there too. The
	// reference needs it more than this port does: an Express router cannot
	// know where app.use mounted it, while an adapter here is mounted at the
	// prefix it was configured with. Set it when the routes are reachable from
	// outside under some other path — behind a reverse proxy that adds or
	// strips a segment — so that the document and the page point at the URL a
	// reader can actually fetch.
	//
	// It moves the description, never the mount: the two routes are always
	// served at HTTPConfig.Prefix(), because that is where the adapter is.
	BasePath string
}

DocsOptions is HTTPConfig.Docs: whether the adapters mount the two documentation routes, and under which base path the document describes itself.

type EmailChangeDelivery added in v0.3.0

type EmailChangeDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	// NewEmail is the pending address the message goes to.
	NewEmail string `json:"newEmail"`
	// Token is the plaintext token POST <prefix>/change-email/confirm accepts.
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expiresAt"`
	// LinkBase and Lang are what PasswordResetDelivery's are
	// (auth.router.ts:1025-1028).
	LinkBase string `json:"linkBase,omitempty"`
	Lang     string `json:"lang,omitempty"`
}

EmailChangeDelivery is what an EmailChangeSender is handed.

NewEmail is the only address here, and it is the recipient: this message verifies that the new mailbox exists, so it goes to the new mailbox (auth.router.ts:1027-1032 mails newEmail; wire-contract §2 "sent to the new address"). The current address is deliberately absent — the delivery carries only what the message needs, and a sender that wants the old address has UserID to read it back with.

The notice the reference mails the *old* address once the change is applied (config.email.sendEmailChanged from /change-email/confirm, auth.router.ts:1060-1066) is a different delivery: EmailChangedDelivery, handed to Config.SendEmailChanged.

type EmailChangeMailer added in v0.3.0

type EmailChangeMailer struct{ TokenMailer }

EmailChangeMailer is the ready-made EmailChangeSender. It mails the pending address the verify-email template (TemplateVerifyEmail) under the change-email confirmation link, which is exactly what the reference does: /change-email/request has no template of its own and calls sendVerificationEmail with its own link (auth.router.ts:1027-1032, mailer.service.ts:200-203). The 0.3.x email_change template is an alias of the same id now (see templateAliases); a deployment that wants different wording for this mail registers it under TemplateVerifyEmail on a separate templater, or stores an override.

func NewEmailChangeMailer added in v0.3.0

func NewEmailChangeMailer(transport MailerTransport, appName, baseURL string) *EmailChangeMailer

NewEmailChangeMailer builds an EmailChangeMailer with the built-in templates.

func (*EmailChangeMailer) Send added in v0.3.0

Send renders and delivers the email-change mail to the new address. Its signature is EmailChangeSender's.

type EmailChangeSender added in v0.3.0

type EmailChangeSender func(ctx context.Context, delivery EmailChangeDelivery) error

EmailChangeSender delivers an email-change token to the pending address. See EmailVerificationSender for the failure contract.

type EmailChangeStore

type EmailChangeStore interface {
	UpdateEmailChangeToken(ctx context.Context, userID, tenantID, pendingEmail, tokenHash string, expiry time.Time) error
	GetUserByEmailChangeTokenHash(ctx context.Context, tokenHash string) (User, error)
	ApplyEmailChange(ctx context.Context, userID, tenantID string) error
	ClearEmailChangeToken(ctx context.Context, userID, tenantID string) error
}

EmailChangeStore persists pending email change flow.

type EmailChangedDelivery added in v0.4.0

type EmailChangedDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	// OldEmail is the address the notice goes to: the one the account had
	// until the change was applied (auth.router.ts:1056, 1062).
	OldEmail string `json:"oldEmail"`
	// NewEmail is the address the account has now (:1057, 1062).
	NewEmail string `json:"newEmail"`
	// Lang is ConfirmEmailChangeInput.Lang, copied through untouched. The
	// routes leave it empty — the reference calls sendEmailChanged with no
	// language (:1062, 1065) — so EmailChangedMailer renders in its Locale.
	Lang string `json:"lang"`
}

EmailChangedDelivery is what an EmailChangedSender is handed: the notice that POST <prefix>/change-email/confirm sends to the address an account just moved away from (auth.router.ts:1060-1066, "Send notification to old address").

OldEmail is the recipient; NewEmail is what the notice reports. There is no token and no link — this mail confirms nothing, it informs — which is why it carries neither. The JSON tags are the reference's argument names in camelCase, so a sender that forwards the delivery as JSON does so in the family's spelling.

type EmailChangedMailer added in v0.4.0

type EmailChangedMailer struct {
	// Transport delivers the rendered message. Required.
	Transport MailerTransport
	// Locale is what TokenMailer.Locale is. The routes hand this mailer no
	// language at all (EmailChangedDelivery.Lang), so it is usually the rule
	// here rather than the default.
	Locale string
	// Templates renders subject and bodies. NewEmailChangedMailer fills this in.
	Templates *MailTemplater
}

EmailChangedMailer is the ready-made EmailChangedSender: it renders the email-changed template (TemplateEmailChanged) with the new address as its data and mails it to the old one, as the reference's sendEmailChanged does (mailer.service.ts:205-208). It has no BaseURL because the notice carries no link.

auth.WithEmailChangedSender(auth.NewEmailChangedMailer(mailer, "Example App").Send)

func NewEmailChangedMailer added in v0.4.0

func NewEmailChangedMailer(transport MailerTransport, appName string) *EmailChangedMailer

NewEmailChangedMailer builds an EmailChangedMailer with the built-in templates.

func (*EmailChangedMailer) Send added in v0.4.0

Send renders and delivers the notice to the old address. Its signature is EmailChangedSender's.

type EmailChangedSender added in v0.4.0

type EmailChangedSender func(ctx context.Context, delivery EmailChangedDelivery) error

EmailChangedSender delivers the email-changed notice to the old address.

An error fails POST <prefix>/change-email/confirm with the reference's generic 500 — its sendEmailChanged call sits inside the route's try block, after the change has been written (auth.router.ts:1058-1066, handleError at :1068-1069) — so the address has moved even though the response says 500. The failure contract is EmailVerificationSender's; the difference is what has already happened when it fires.

type EmailVerificationDelivery added in v0.3.0

type EmailVerificationDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	Email    string `json:"email"`
	// Token is the plaintext token GET <prefix>/verify-email accepts.
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expiresAt"`
	// LinkBase and Lang are what PasswordResetDelivery's are
	// (auth.router.ts:941, 954-957).
	LinkBase string `json:"linkBase,omitempty"`
	Lang     string `json:"lang,omitempty"`
}

EmailVerificationDelivery is what an EmailVerificationSender is handed. The route behind it is authenticated, so Email is the caller's own stored address.

type EmailVerificationInput

type EmailVerificationInput struct {
	UserID   string
	TenantID string
	// LinkBase and Lang are what ForgotPasswordInput's are, for
	// EmailVerificationDelivery (auth.router.ts:941, 955-957).
	LinkBase string
	Lang     string
}

type EmailVerificationMailer added in v0.3.0

type EmailVerificationMailer struct{ TokenMailer }

EmailVerificationMailer is the ready-made EmailVerificationSender, rendering the verify-email template (TemplateVerifyEmail) as the reference's sendVerificationEmail does (mailer.service.ts:200-203). See PasswordResetMailer for the usage shape.

func NewEmailVerificationMailer added in v0.3.0

func NewEmailVerificationMailer(transport MailerTransport, appName, baseURL string) *EmailVerificationMailer

NewEmailVerificationMailer builds an EmailVerificationMailer with the built-in templates.

func (*EmailVerificationMailer) Send added in v0.3.0

Send renders and delivers the verification mail. Its signature is EmailVerificationSender's.

type EmailVerificationSender added in v0.3.0

type EmailVerificationSender func(ctx context.Context, delivery EmailVerificationDelivery) error

EmailVerificationSender delivers an email-verification token. An error fails the route with the reference's generic 500 and leaves the stored token in place, as MagicLinkSender does.

type EmailVerificationStore

type EmailVerificationStore interface {
	UpdateEmailVerificationToken(ctx context.Context, userID, tenantID, tokenHash string, expiry time.Time) error
	GetUserByEmailVerificationTokenHash(ctx context.Context, tokenHash string) (User, error)
	MarkEmailVerified(ctx context.Context, userID, tenantID string, verified bool) error
	ClearEmailVerificationToken(ctx context.Context, userID, tenantID string) error
}

EmailVerificationStore persists verification flow.

type Event

type Event struct {
	Name      string
	UserID    string
	TenantID  string
	Timestamp time.Time
	Data      map[string]any
}

Event is a generic auth domain event payload.

type EventBus

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

EventBus is a tiny in-process pub/sub for auth events.

func NewEventBus

func NewEventBus() *EventBus

func (*EventBus) Publish

func (b *EventBus) Publish(ev Event)

func (*EventBus) Subscribe

func (b *EventBus) Subscribe(event string, handler func(Event))

type ForgotPasswordInput

type ForgotPasswordInput struct {
	Email    string
	TenantID string
	// LinkBase is the per-request base the reset link is built under, copied
	// onto PasswordResetDelivery.LinkBase. The adapters fill it from
	// HTTPConfig.LinkBase(Auth.ResolveSiteURL(r)); a direct caller may leave it
	// empty, in which case a ready-made mailer uses its static BaseURL.
	LinkBase string
	// Lang is the request's emailLang body field, copied onto
	// PasswordResetDelivery.Lang untouched (auth.router.ts:779, 788).
	Lang string
}

type HTTPConfig added in v0.2.0

type HTTPConfig struct {
	APIPrefix string
	Cookies   CookieOptions
	CSRF      CSRFConfig
	// UI is the reference's config.ui block (auth-config.model.ts:321-381): the
	// built-in UI's branding, language default and headless flag, and the switch
	// that mounts it. UIOptions.Enabled is what makes the adapters register
	// GET <prefix>/ui/config, and what points an emailed link at a UI page — see
	// UILink.
	UI UIOptions
	// UIEnabled is the deprecated spelling of UI.Enabled, from when the flag
	// decided nothing but the shape of an emailed link: when set, UILink points
	// a link at <prefix>/ui/<path> — the static UI's page for it — instead of at
	// the bare API route (buildUiLink, auth.router.ts:265-266).
	//
	// It is an alias, not a second switch. Either field being set enables the
	// UI, resolve sets both from either, and a configuration that only ever set
	// this one keeps the links it had and now serves the config route as well.
	//
	// Deprecated: set UI.Enabled instead. This field is kept through the 0.x
	// line.
	UIEnabled bool
	// ResourceServer mounts this instance as a resource server: an API that
	// verifies tokens another instance issued and owns no credentials of its
	// own. The adapters then register none of the routes in
	// ResourceServerGatedRoutes, so a credential route answers with whatever
	// the router returns for an unknown path — 404 on all four — instead of
	// reaching a handler that has no user database behind it.
	//
	// It changes nothing else: the routes that stay are unaffected, and the
	// bearer verification itself is ResourceServerMiddleware, which a host
	// mounts on its own routes. The reference gates six of the nineteen
	// (auth.router.ts:510, :541); gating all of them is the
	// resource-server-gates-all-credential-routes deviation.
	ResourceServer bool
	// RateLimiter is the reference's RouterOptions.rateLimiter
	// (auth.router.ts:46): the slot a host drops its own limiter into. nil —
	// the default — means no limiter, which is the reference's own default:
	// it collapses the slot to an empty middleware list when the option is
	// absent (rl, auth.router.ts:468) and ships no algorithm of its own.
	//
	// The adapters apply it to every route of the auth router, the routes the
	// reference spreads ...rl onto (auth.router.ts:541 onwards), and not to the
	// JWKS document, which is registered ahead of the whole chain and carries
	// no rl (:490).
	//
	// One pair of routes falls inside the slot here that falls outside it
	// there. When an OAuth provider is not configured the reference registers
	// bare 404 stubs in place of the real handlers, with no rl on them
	// (:1361-1362 for Google, :1407-1408 for GitHub); this port mounts one
	// always-guarded handler per OAuth route and decides on the configuration
	// inside it. Behind a limiter at its limit those two requests are therefore
	// answered 429 here and 404 there, and they consume budget here that they
	// do not consume there.
	//
	// Position in the chain is the load-bearing part. The reference puts rl
	// first on each route, ahead of the auth middleware (GET /me,
	// auth.router.ts:656) and therefore ahead of the double-submit check,
	// which the auth middleware performs and which the one route without that
	// middleware repeats by hand inside its handler (:1489-1495). A refusal
	// consequently costs nothing downstream: no token is verified, no store is
	// read, no CSRF comparison is made. The adapters reproduce that order —
	// RateLimiter outermost, then CSRFMiddleware, then the auth middleware.
	//
	// The field is a constructor, not a handler, and each adapter calls it once
	// per route at mount time — some thirty calls for one mount. Whatever the
	// limiter counts with, a bucket or a map or a store handle, therefore has
	// to be created outside this function and captured by it, the way
	// httprate.LimitByIP and the tollbooth wrappers are written. Allocating the
	// counter inside the function gives every route a private budget instead of
	// the single shared one the reference gets from passing one Express handler
	// instance to every route.
	//
	// Note for the admin router when it lands: the family's private development
	// line carries a second, separate slot on its admin router
	// (AdminOptions.rateLimiter, admin.router.ts:211, collapsed to an empty
	// list the same way at :577) and spreads it onto exactly one route, POST
	// /users/:id/promote (:1030); its admin login route (:614) carries none.
	// That path is relative to the admin router's own mount, which the host app
	// chooses and which defaults to /admin (admin.router.ts:190,
	// openapi.ts:674) — not to APIPrefix. These line numbers are in that
	// private tree and do not resolve against ReferenceRevision, which carries
	// no admin rateLimiter at all. No admin route exists here yet, so nothing
	// below implements that half.
	RateLimiter func(http.Handler) http.Handler

	// Docs mounts the two documentation routes the reference's swagger option
	// registers: GET <prefix>/openapi.json and GET <prefix>/docs, neither with
	// a guard of its own (auth.router.ts:1656-1677), both behind the CSRF
	// middleware as every reference route registered after its router-level
	// auto-init is (:529-538) — which on a GET distributes the csrf-token
	// cookie and rejects nothing. Nothing is mounted unless Docs.Enabled is
	// set; see DocsOptions in docs.go for that field and for why it is a bool
	// where the reference's option is also the string "auto".
	Docs DocsOptions
}

HTTPConfig is the wire configuration an adapter mounts with. The four adapters take the same value, so a change here reaches all of them.

func DefaultHTTPConfig added in v0.2.0

func DefaultHTTPConfig() HTTPConfig

DefaultHTTPConfig returns the conventions an adapter uses when the host app does not configure them.

func (c HTTPConfig) AccountConflictLink(siteURL, provider, email string) string

AccountConflictLink is the Location the callback answers an account conflict with: the reference's buildUiLink(siteUrl, "/account-conflict?…") (auth.router.ts:261-271, :1352-1354). siteURL is the origin the state resolved to — OAuthCompleteResult.RedirectTo, which is the same resolveOAuthRedirect value the reference passes — and the query is its literal one, provider first, then code, then the address when there is one.

The escaping is Go's url.QueryEscape where the reference calls encodeURIComponent. The two agree on every character an address or a provider name can hold in practice; they differ on a space (+ against %20) and on !~*'(), which QueryEscape percent-encodes and encodeURIComponent leaves alone. Both forms decode to the same string.

func (HTTPConfig) DocsBasePath added in v0.7.0

func (c HTTPConfig) DocsBasePath() string

DocsBasePath is DocsOptions.BasePath resolved: the configured value, or the mount prefix when none is configured.

func (HTTPConfig) LinkBase added in v0.4.0

func (c HTTPConfig) LinkBase(siteURL string) string

LinkBase is the base MagicLinkURL, PasswordResetURL, EmailVerificationURL and EmailChangeConfirmURL take, resolved for one site URL: UILink(siteURL, "") with the trailing slash removed, which is what the reference does with that value on the one route that passes an empty path (magic-link.strategy.ts:25-27). "https://app.example.com" becomes "https://app.example.com/auth", or "https://app.example.com/auth/ui" under UIEnabled. The adapters put it on each delivery as LinkBase.

An empty siteURL yields "" rather than the bare prefix: no site URL is known for the request, and a delivery with no LinkBase lets a ready-made mailer fall back to its static BaseURL — which a deployment configured before Config.SiteURLs existed relies on. The reference has no such static base and builds a relative link in that case; the port keeps the link that used to be sent.

func (HTTPConfig) Prefix added in v0.2.0

func (c HTTPConfig) Prefix() string

Prefix returns the normalised mount prefix.

func (c HTTPConfig) UILink(siteURL, path string) string

UILink is the reference's buildUiLink (auth.router.ts:261-271): the link to path under siteURL, through the static UI when the UI is enabled and straight at the API route otherwise.

<siteURL><prefix>/ui/<path>   when UI.Enabled
<siteURL><prefix>/<path>      otherwise

Either UI.Enabled or the deprecated UIEnabled switches it, so a config built before UIOptions existed keeps the links it had.

prefix is Prefix() — the reference strips one trailing slash from its apiPrefix (:263), which Prefix() already does along with the rest of this port's normalisation — and one leading slash is dropped from path (:264), so UILink("https://app.example.com", "/reset-password?token=t") is "https://app.example.com/auth/reset-password?token=t". siteURL is used as given: an empty one yields a relative link, exactly as the reference does with no siteUrl configured.

func (HTTPConfig) WriteTokens added in v0.2.0

func (c HTTPConfig) WriteTokens(w http.ResponseWriter, r *http.Request, status int, tokens AuthTokens, extra map[string]any)

WriteTokens delivers an issued token pair. A bearer caller gets the tokens in the body and no cookies; a cookie caller gets the cookies and a body that never contains a token. extra carries any route-specific top-level fields, such as register's userId.

type HTTPError added in v0.2.0

type HTTPError struct {
	Status  int    `json:"-"`
	Message string `json:"error"`
	Code    string `json:"code,omitempty"`
}

HTTPError is the family error envelope together with its status code.

The "code" field is omitted when the reference emits no code for that failure; clients are documented not to pattern-match on an absent code.

func AccessHTTPError added in v0.2.0

func AccessHTTPError(err error) HTTPError

AccessHTTPError maps an access-token verification failure the way the reference auth middleware does.

func ChangeEmailConfirmHTTPError added in v0.2.0

func ChangeEmailConfirmHTTPError(err error) HTTPError

ChangeEmailConfirmHTTPError maps a Service.ConfirmEmailChange failure.

func ChangeEmailInlineError added in v0.2.0

func ChangeEmailInlineError(user User) (HTTPError, bool)

ChangeEmailInlineError reproduces the reference's PASSWORD_REQUIRED guard on /change-email/request: an account whose only credential is the address itself may not move that address (auth.router.ts:1015-1021).

The reference runs this check after the 409 and 404 branches; here it runs before them, because the service persists the pending change in the same call that would report those two failures and there is no point at which the guard could still run without leaving a token behind. The status and code are therefore exact, and only the precedence between two simultaneous failures differs.

func ChangeEmailRequestHTTPError added in v0.2.0

func ChangeEmailRequestHTTPError(err error) HTTPError

ChangeEmailRequestHTTPError maps a Service.RequestEmailChange failure.

As on /send-verification-email, the 404 branch is a store-race path only: this port's auth gate answers 403 where the reference's handler would answer 404.

func ChangePasswordHTTPError added in v0.2.0

func ChangePasswordHTTPError(err error) HTTPError

ChangePasswordHTTPError maps a Service.ChangePassword failure.

ErrInvalidCredentials becomes 401 "Current password is incorrect" rather than the reference's 404 "User not found", because the service returns the same sentinel for both and the auth middleware resolved this user from the store moments earlier: a wrong password is the reachable case, a row that vanished in between is not.

func ChangePasswordInlineError added in v0.2.0

func ChangePasswordInlineError(user User, currentPassword, newPassword string) (HTTPError, bool)

ChangePasswordInlineError reproduces the reference's inline validation on /change-password: an account with no stored password that submits neither field gets 400 "New password is required" (auth.router.ts:922-925). An account that does have a password never reaches it — a missing current password fails the comparison instead.

func ForgotPasswordHTTPError added in v0.2.0

func ForgotPasswordHTTPError(error) HTTPError

ForgotPasswordHTTPError maps a Service.ForgotPassword failure.

Every failure is a bare 500: the reference has no error branch on this route at all, so a throwing store reaches handleError and becomes {"error":"Internal server error"} (auth.router.ts:796-798). ErrFeatureNotSupported is included on purpose — a store that cannot persist a reset token is a deployment fault, not a feature the caller can be told about, and answering 501 would tell an anonymous caller something the 200-always rule is there to hide.

A failed *delivery* never reaches this mapper: Auth.ForgotPassword absorbs it so the route keeps answering 200. See there for why that one diverges.

func HTTPErrorFor added in v0.2.0

func HTTPErrorFor(err error) HTTPError

HTTPErrorFor maps a service sentinel onto the envelope. Routes whose failure means something more specific than the sentinel says — /refresh and the auth middleware both narrow "invalid token" — use their own mapper below.

Anything unmapped becomes a 500, so a sentinel that reaches a route before it reaches this switch turns a client-visible failure into an internal error. The sentinels deliberately left out are the ones whose reference wire string is route-specific and must not be guessed here: ErrInvalidCode is "Invalid or expired SMS code" on /sms/verify and "Invalid TOTP code" on /2fa/verify (both 401, both code-less), and ErrTenantNotFound / ErrRoleNotFound belong to admin routes. A route that can return one of those has to write it itself. TestUnmappedSentinelsAreDeliberate keeps that list honest.

func MagicLinkVerifyHTTPError added in v0.2.0

func MagicLinkVerifyHTTPError(err error) HTTPError

MagicLinkVerifyHTTPError maps a Service.VerifyMagicLink or Service.VerifyMagicLinkForUser failure. See HTTPErrInvalidMagicLink for why the expiry code does not appear.

func OAuthHTTPError added in v0.2.0

func OAuthHTTPError(err error) HTTPError

OAuthHTTPError maps a flow failure from this file onto the wire envelope. Routes call it instead of WriteServiceError because every literal here is route-specific: HTTPErrorFor would turn each of them into a 500.

*OAuthAccountConflictError is deliberately absent: the reference answers that one with a 302 to /account-conflict, not with an error body (auth.router.ts:1346-1355), so the callback handles it before it gets here and anything else that reaches this function with it has lost the redirect.

func RefreshHTTPError added in v0.2.0

func RefreshHTTPError(err error) HTTPError

RefreshHTTPError maps a Service.Refresh failure. A revoked session stays distinguishable from an unusable token, which is the whole point of the code.

func ResetPasswordHTTPError added in v0.2.0

func ResetPasswordHTTPError(err error) HTTPError

ResetPasswordHTTPError maps a Service.ResetPassword failure.

func SMSVerifyHTTPError added in v0.2.0

func SMSVerifyHTTPError(err error) HTTPError

SMSVerifyHTTPError maps a Service.VerifySMSCode failure.

ErrInvalidCredentials means the userId or tempToken named a user the store does not have, and that is 401 "Invalid or expired SMS code", not 404. The reference looks the user up inside the strategy, where a miss is indistinguishable from a bad code: sms.strategy.ts:24-25 returns false for an unknown id, and the router turns false into 401 {"error":"Invalid or expired SMS code"} (auth.router.ts:1275-1279). Its 404 (:1281-1284) sits *after* that check and only fires if the user vanished between the verify and the follow-up findById — a race this port cannot reproduce, because Service.VerifySMSCode does both in one call. Answering 404 here would both change the status a client sees for a stale userId and turn an unauthenticated route into a user-existence oracle the reference does not have.

func SendVerificationEmailHTTPError added in v0.2.0

func SendVerificationEmailHTTPError(err error) HTTPError

SendVerificationEmailHTTPError maps a Service.SendVerificationEmailToken failure.

The 404 "User not found" branch is not reachable over HTTP in this port and is kept only for a store race: the reference's auth gate trusts the JWT and never reads the store (auth.middleware.ts:60), so its handler's own lookup can miss and answer 404, whereas this port's gate resolves the user through Me() and answers 403 "Invalid or expired access token" for a row that no longer exists. The divergence belongs to the shared middleware, not to this route.

func TOTPSetupHTTPError added in v0.2.0

func TOTPSetupHTTPError(err error) HTTPError

TOTPSetupHTTPError maps a Service.VerifyTOTPSetup failure (POST /2fa/verify-setup), where a bad code is a 400 rather than the 401 the step-up route answers.

func TOTPVerifyHTTPError added in v0.2.0

func TOTPVerifyHTTPError(err error) HTTPError

TOTPVerifyHTTPError maps a Service.VerifyTOTP failure (POST /2fa/verify).

func VerifyEmailHTTPError added in v0.2.0

func VerifyEmailHTTPError(err error) HTTPError

VerifyEmailHTTPError maps a Service.VerifyEmail failure.

func (HTTPError) Error added in v0.2.0

func (e HTTPError) Error() string

type HTTPMailerTransport deprecated

type HTTPMailerTransport struct {
	EndpointURL string
	Secret      string
	// contains filtered or unexported fields
}

HTTPMailerTransport sends mail via any HTTP endpoint (no SMTP needed).

Deprecated: it POSTs MailMessage as PascalCase JSON ({"To", "Subject", "Body", "IsHTML"}) under an X-Mailer-Secret header — a request of this port's own that no gateway built for the reference accepts. NewGatewayMailerTransport sends the reference's contract instead. This type keeps its 0.3.0 behaviour unchanged for the gateways built against it and is scheduled for removal in v1.0.0.

func NewHTTPMailerTransport deprecated

func NewHTTPMailerTransport(endpointURL, secret string) *HTTPMailerTransport

NewHTTPMailerTransport creates a transport that POSTs to a webhook-style HTTP endpoint.

Deprecated: use NewGatewayMailerTransport; see HTTPMailerTransport for why.

func (*HTTPMailerTransport) Send

type HTTPSMSTransport added in v0.3.0

type HTTPSMSTransport struct {
	EndpointURL string
	APIKey      string
	Username    string
	Password    string
	// contains filtered or unexported fields
}

HTTPSMSTransport talks to the gateway contract the rest of this family talks to: a GET against the endpoint carrying username, password, phone and message as query parameters, with the API key in an X-API-Key header (awesome-node-auth src/services/sms.service.ts:16-46, documented in config-schema.md §1.6).

The credentials travel in the URL. That is a real hazard — query strings reach access logs, proxies and Referer headers — and the spec calls it out as one (config-schema.md §1.6, "credentials-in-URL hazard the product must fix"). It is reproduced here anyway, because the endpoint on the other side is the deployment's existing gateway and it is the request that gateway is built to accept; a "fixed" request would simply fail to send. The fix belongs at the gateway, and until it happens the way out is the seam itself: Config.SendSMSCode takes any SMSCodeSender, so a deployment whose provider accepts a safer shape supplies its own transport and never constructs this one.

func NewHTTPSMSTransport added in v0.3.0

func NewHTTPSMSTransport(endpointURL, apiKey, username, password string) *HTTPSMSTransport

NewHTTPSMSTransport creates a transport for a query-parameter SMS gateway.

func (*HTTPSMSTransport) Send added in v0.3.0

func (t *HTTPSMSTransport) Send(ctx context.Context, phone, message string) error

type IDP

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

IDP is an OIDC Identity Provider.

func NewIDP

func NewIDP(cfg IDPConfig, authSvc *Service, clients ...IDPClient) (*IDP, error)

NewIDP creates a new OIDC IDP backed by the given auth service.

A nil cfg.Signer falls back to an ephemeral RSA-2048 key and logs idpEphemeralKeyWarning once; a signer that is not RSA is refused. A nil cfg.Codes falls back to an in-process MemoryAuthCodeStore; see IDPConfig.Codes for when that is not enough.

func (*IDP) IssueIdPTokenPair added in v0.5.0

func (idp *IDP) IssueIdPTokenPair(ctx context.Context, user User) (AuthTokens, error)

IssueIdPTokenPair mints the reference's RS256 token pair for user: the transcription of generateIdProviderTokenPair (token.service.ts:73-95).

The payload is the reference's buildPayload (auth.router.ts:378-384): the six base claims sub, email, role, loginProvider, isEmailVerified and isTotpEnabled with Config.BuildTokenClaims spread over them, the same object the session tokens start from. iat, exp and kid are then dropped from it (:73) — the first two are set below, kid is a header parameter — and iss is set from IDPConfig.Issuer when that is configured, else left as the hook left it (:74-77). Both tokens carry that payload plus iat and exp, both are RS256 under KeyID (:81-93); they differ only in lifetime — AccessTokenTTL, 30 days by default, and RefreshTokenTTL, 90 days by default. ExpiresIn is the access lifetime.

No session is created and no sid, jti or typ claim is written: the reference pair has none either, so the refresh token is a longer-lived copy of the access token that nothing here can revoke. This is a host-level API exactly as in the reference, where nothing in src/ calls generateIdProviderTokenPair (reference-issues N35): /login, /refresh and the OIDC /token keep issuing the HS256 session pair, and enabling the IDP changes nothing about them.

It lives on IDP rather than Service because everything it needs beyond the user is the IDP's — the signer, the kid, the two lifetimes, the issuer — and the IDP already holds the Service for the claims hook, while a Service knows nothing of any IDP. In the reference all of that is the config.idProvider block, which is IDPConfig here.

func (*IDP) JWKS added in v0.5.0

func (idp *IDP) JWKS() JWKS

JWKS is the document the JWKS endpoint serves: the signer's public key under KeyID first, then IDPConfig.PublicKeys in the order given. The reference builds the same document from its one key (jwks.service.ts:184-186); the extra entries are what a rotation publishes so that tokens signed by the previous key stay verifiable until they expire.

func (*IDP) JWKSHandler added in v0.6.0

func (idp *IDP) JWKSHandler() http.Handler

JWKSHandler serves JWKS() with the headers the reference sets on that route: Access-Control-Allow-Origin per JWKSCORSOrigins and "Cache-Control: public, max-age=3600" (auth.router.ts:490-503).

The document is built once, here, and not per request: the reference derives it at router-creation time and closes over it (auth.router.ts:487), and nothing an IDP holds can change it afterwards — the signer and IDPConfig.PublicKeys are both fixed at construction.

The handler is public by construction: no auth, no CSRF, no session. Mounting it is what an adapter does for an Auth built WithIDP; a host that mounts it by hand must keep it ahead of any middleware, as the reference does (auth.router.ts:473-474).

func (*IDP) JWKSPath added in v0.6.0

func (idp *IDP) JWKSPath() string

JWKSPath reports where the JWKS document is served relative to the mount prefix: IDPConfig.JWKSPath, or DefaultJWKSPath when that is empty. It always starts with "/", because NewIDP refuses a path that does not. The adapters call it to build the route they mount.

func (*IDP) OIDCMounts added in v0.7.0

func (idp *IDP) OIDCMounts() []OIDCMount

OIDCMounts returns the four OIDC endpoints — discovery, authorize, token and userinfo — as the adapters and RegisterHandlers mount them.

It is the one list: every adapter's MountWithConfig walks it, and so does RegisterHandlers, so an endpoint cannot appear on one mount and not another. The JWKS route is not in it — that one is the reference's, configurable through IDPConfig.JWKSPath and GET-only, and the adapters mount it from JWKSPath() and JWKSHandler() for that reason.

Two of the four carry ResourceServerGated, which is the adapters' cue to skip them under HTTPConfig.ResourceServer; the flag travels on the mount rather than splitting the walk in four adapters, so this stays the one list.

These four have no counterpart in the reference, which ships no OIDC authorization server; their shapes are described where the handlers are, and nothing here changes them.

func (*IDP) RegisterHandlers

func (idp *IDP) RegisterHandlers(mux *http.ServeMux, basePath string)

RegisterHandlers mounts OIDC endpoints on the given mux.

An Auth built with WithIDP makes all four adapters mount the same four endpoints under the adapter's own prefix, from the same OIDCMounts list, so a host that mounts an adapter no longer needs this at all. It stays for the host that serves the OIDC endpoints on a mux of its own — under a different base path, or behind its own middleware — and for the one that uses the IdP without mounting an adapter.

All four are mounted here whatever OIDCMount.ResourceServerGated says. That flag is the adapters' reading of HTTPConfig.ResourceServer, and this method is handed a mux with no HTTPConfig behind it: a host mounting the endpoints itself is choosing the surface itself.

Calling it with the adapter's own prefix, on the mux the adapter was mounted on, is a mistake rather than a way to mount the endpoints twice, and it is a loud one: both registrations use the same method-less pattern, so the second panics at mount time — ServeMux reports the two patterns as conflicting and names both registration sites — the same way the JWKS route already panics when a host registers it twice. The panic is the point. A pattern that did not collide, "GET <path>" against this method-less one, would be accepted as the more specific of the two and would leave the endpoint split between two handlers, GET answered by the adapter's mount and every other method by this one; that silent split is the failure this shape rules out.

The JWKS document is served at basePath + JWKSPath(), the canonical location the adapters mount and the one the discovery document points at. <basePath>/jwks is kept as a deprecated alias of it, serving the identical bytes and headers, for the relying parties configured against the path this package published before v0.6.0; the alias is kept through the 0.x line and removed in v1.0.0. The deprecated thing is that URL path, not this method: the other four endpoints, and the canonical JWKS path, are current.

Both JWKS patterns are registered for GET alone, as the reference registers the route (router.get, auth.router.ts:490) and as the four adapters mount it. net/http serves HEAD from a "GET " pattern itself, which is Express's own fallback from HEAD to the GET handler; any other method misses the route.

type IDPClient

type IDPClient struct {
	ClientID     string
	ClientSecret string
	RedirectURIs []string
	Name         string
}

IDPClient represents a registered OIDC client application.

type IDPConfig

type IDPConfig struct {
	// Issuer is the iss claim of the ID token and the base of the discovery
	// document. For IssueIdPTokenPair it is optional, as the reference's
	// idProvider.issuer is: left empty, the pair carries no iss claim
	// (token.service.ts:74-77).
	Issuer string
	// AccessTokenTTL is the lifetime of the access token IssueIdPTokenPair
	// mints; zero resolves to 30 days, the reference's tokenExpiry default
	// (token.service.ts:80). It governs only that pair: the OIDC /token
	// endpoint returns the HS256 session access token and reports that
	// token's lifetime, Config.AccessTokenTTL, as expires_in.
	AccessTokenTTL time.Duration
	// IDTokenTTL is the lifetime of the ID token; zero resolves to one hour.
	IDTokenTTL time.Duration

	// Signer signs every RS256 token the IDP issues and is the key the JWKS
	// document publishes first. It must be an RSA key — RS256 is RSASSA-PKCS1
	// v1.5 over SHA-256 (RFC 7518 section 3.3) — and NewIDP refuses a signer
	// whose Public is not an *rsa.PublicKey. An *rsa.PrivateKey qualifies, so
	// the reference's PEM configuration is ParseRSAPrivateKeyPEM(pem) here
	// (idProvider.privateKey, token.service.ts:47); the public key the
	// reference derives when none is given (:67-69) is Signer.Public. A signer
	// that keeps the private key elsewhere — a KMS, an HSM — plugs in the same
	// way; BuildRS256JWT says what it is asked to do.
	//
	// Nil generates an ephemeral RSA-2048 key in NewIDP and logs the
	// reference's warning once (token.service.ts:50-66, auth.router.ts:480-483):
	// every token becomes unverifiable on restart, and two processes never
	// share a key. Development only.
	Signer crypto.Signer
	// KeyID is the kid written into the header of every signed token and
	// published with Signer's key in the JWKS document. Empty resolves to
	// DefaultIDPKeyID, the reference's constant.
	KeyID string
	// PublicKeys are further keys the JWKS document publishes after Signer's,
	// unchanged: the previous key during a rotation, so that the tokens it
	// signed stay verifiable until they expire, or a sibling issuer's. Nothing
	// in the IDP signs with them or checks them; NewRSAJWK builds one from an
	// *rsa.PublicKey. The reference publishes exactly one key
	// (jwks.service.ts:184-186); this is the structural room a rotation needs.
	PublicKeys []JWK
	// RefreshTokenTTL is the lifetime of the refresh token IssueIdPTokenPair
	// mints; zero resolves to 90 days (token.service.ts:91). The reference
	// consults its session refresh lifetime in between (idProvider.refreshTokenExpiry,
	// then refreshTokenExpiresIn, then 90d); that middle step is not
	// reproduced, because Config.RefreshTokenTTL is never unset here —
	// DefaultConfig fills it and validate requires it — so honouring it would
	// make the 90-day default unreachable and tie the IdP pair silently to the
	// session lifetime.
	RefreshTokenTTL time.Duration
	// Logger receives the IDP's diagnostics, today only the ephemeral-key
	// warning. Nil falls back to the Config.Logger of the Service NewIDP was
	// given, and to silence when there is none.
	Logger func(format string, args ...any)

	// Codes holds authorization codes between /authorize and /token. Nil
	// selects NewMemoryAuthCodeStore, which is correct only while both
	// endpoints are served by the same process: a serverless runtime or any
	// deployment with more than one instance must supply a shared
	// implementation, or /token will answer invalid_grant for every code
	// minted by a sibling process. See AuthCodeStore for the contract.
	Codes AuthCodeStore

	// CodeTTL bounds how long a code issued by /authorize can be redeemed at
	// /token. Zero (or negative) resolves to defaultAuthCodeTTL, five minutes.
	CodeTTL time.Duration

	// JWKSPath is where the JWKS document is served, relative to the adapter's
	// mount prefix: the reference's idProvider.jwksPath (auth.router.ts:475).
	// Empty resolves to DefaultJWKSPath. NewIDP refuses a path that the four
	// adapters and RegisterHandlers cannot all route as the same literal path:
	// it must start with "/" and must not end with one, must not contain "{",
	// "}", "?", "#" or "//", and must not collide with one of the four OIDC
	// endpoints, which the adapters and RegisterHandlers both mount
	// (OIDCAuthorizePath, OIDCTokenPath, OIDCUserInfoPath, OIDCDiscoveryPath).
	//
	// An Auth built WithIDP makes every adapter serve GET <prefix><JWKSPath>,
	// public and ahead of any middleware, exactly where the reference registers
	// it (auth.router.ts:473-475).
	JWKSPath string
	// JWKSCORSOrigins is the allowlist for the Access-Control-Allow-Origin
	// header on the JWKS response: the reference's idProvider.jwksCorsOrigins
	// (auth.router.ts:492-500).
	//
	// Nil is the reference's default, "*". Otherwise the request's Origin is
	// echoed back when it appears in the list, and no Access-Control-Allow-Origin
	// header is sent at all when it does not — not an empty one, and not a
	// refusal: the body is served either way, since CORS bounds what a browser
	// script may read, not what the server answers.
	//
	// The reference's type is `string | string[]`, and only its string form is
	// the wildcard: it compares the whole value against "*" (auth.router.ts:493),
	// so every array is an allowlist and an entry "*" inside one is an ordinary
	// entry, matching only an Origin header of literally "*" — one no browser
	// sends. Go has no such union, so the one-element slice {"*"} stands in for
	// the string form and is the single configuration that reads differently
	// from the reference; see the jwks-cors-wildcard-string-form entry in
	// compatibility.go.
	//
	// Neither the reference nor this port sends Vary: Origin, while both send
	// Cache-Control: public, max-age=3600 (auth.router.ts:492-502). An
	// allowlisted response must therefore not reach a shared cache, which would
	// store one origin's header and replay it to another, or store the
	// header-less variant and break the allowlisted origin: front an allowlist
	// with a private cache, or leave this nil so every origin may read the
	// document.
	JWKSCORSOrigins []string
	// JWKSURL overrides the jwks_uri of the discovery document RegisterHandlers
	// serves. Empty derives it as Issuer + JWKSPath, which is the canonical
	// location whenever Issuer carries the mount prefix — the convention every
	// other endpoint in that document already follows, since they are all
	// derived from Issuer alone. Set it when the IdP is reached through a
	// gateway whose external URL is not Issuer + the mounted path.
	JWKSURL string
}

IDPConfig configures the OIDC Identity Provider.

type JWK added in v0.5.0

type JWK struct {
	Kty string `json:"kty"`
	Use string `json:"use"`
	Alg string `json:"alg"`
	Kid string `json:"kid"`
	N   string `json:"n"`
	E   string `json:"e"`
}

JWK is one RSA public key as the JWKS endpoint publishes it: the six members the reference's JWK interface has (jwks.service.ts:5-12), under the same JSON names and in the same order. Only signing keys are published, so Kty is always "RSA", Use "sig" and Alg "RS256" for a key NewRSAJWK built; the fields are exported so that a host can carry a key it obtained elsewhere into IDPConfig.PublicKeys unchanged.

func NewRSAJWK added in v0.5.0

func NewRSAJWK(pub *rsa.PublicKey, kid string) (JWK, error)

NewRSAJWK converts an RSA public key to the JWK the reference's publicKeyToJwk emits (jwks.service.ts:168-179): kty "RSA", use "sig", alg "RS256", the given kid, and n and e as the unpadded base64url of their big-endian bytes (RFC 7518 section 6.3.1).

A nil key, or one with no modulus, is an error rather than a panic: the key a host feeds IDPConfig.PublicKeys was obtained elsewhere (kms:GetPublicKey, a file), and the failed fetch that leaves it nil should surface as an error at the call site, not as a crash when the document is built.

func (JWK) RSAPublicKey added in v0.6.0

func (j JWK) RSAPublicKey() (*rsa.PublicKey, error)

RSAPublicKey converts a published JWK back into an RSA public key — the inverse of NewRSAJWK, and of the reference jwkToPublicKey (jwks.service.ts:199-202).

Only RSA signing keys are accepted: kty must be RSA, and alg, when the document states one, must be RS256. A key published for another algorithm would otherwise be verified as if it were an RS256 key, which is the algorithm-confusion the alg allow-list exists to prevent.

type JWKS added in v0.5.0

type JWKS struct {
	Keys []JWK `json:"keys"`
}

JWKS is the document the JWKS endpoint serves: {"keys": [...]} (jwks.service.ts:14-16, :184-186).

type JWKSClient added in v0.6.0

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

JWKSClient is a cached reader of a remote JWKS document.

The cache is stale-while-revalidate, exactly as the reference (jwks.service.ts:49-92):

  • a document inside its TTL is returned from memory;
  • a fetch already in flight is joined rather than duplicated, so a burst of requests on a cold cache causes one HTTP call;
  • a document past its TTL is returned anyway, immediately, and a refresh runs in the background;
  • a refresh that fails changes nothing, so the stale document keeps serving — an issuer that is briefly unreachable does not take the resource server down with it.

The zero value is not usable; construct one with NewJWKSClient. A client is safe for concurrent use, and is meant to be long-lived: it is the cache.

func NewJWKSClient added in v0.6.0

func NewJWKSClient(cfg ResourceServerConfig) *JWKSClient

NewJWKSClient returns a client for cfg.JWKSURL. Only the JWKS fields of cfg are read; Issuer belongs to the verifier.

It panics when JWKSURL is empty or is not an absolute http or https URL. A resource server whose issuer endpoint is misconfigured cannot verify a single bearer token, so the failure belongs at startup, where it is one line in a log, rather than at the first request, where it is a blanket 401 that looks like an authentication problem. The reference throws for the same reason (jwks-auth.middleware.ts:38-40).

func (*JWKSClient) Document added in v0.6.0

func (c *JWKSClient) Document(ctx context.Context) (JWKS, error)

Document returns the cached JWKS, fetching or refreshing as the stale-while-revalidate rules require. The returned value is a copy down to the key slice, so a caller may sort or filter it freely.

func (*JWKSClient) GetKey added in v0.6.0

func (c *JWKSClient) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error)

GetKey returns the RSA public key published under kid, fetching or refreshing the document as the cache rules require (jwks.service.ts:95-98).

A document that carries no such key is not an error of the transport, and the two are told apart by the caller: the error wraps errJWKSKeyNotFound, which is what makes VerifyRS256 invalidate and retry once instead of giving up. Both forms wrap ErrInvalidToken.

func (*JWKSClient) Invalidate added in v0.6.0

func (c *JWKSClient) Invalidate()

Invalidate drops the cached document so the next read fetches fresh keys. It is what the verifier calls when a token names a kid the cache does not carry, which is how a key rotation is picked up before the TTL expires (jwks.service.ts:101-105).

func (*JWKSClient) URL added in v0.6.0

func (c *JWKSClient) URL() string

URL reports the JWKS endpoint this client reads.

type LinkRequestInput added in v0.2.0

type LinkRequestInput struct {
	Email    string
	Provider string
	// EmailLang is the reference's third body field. It selects the language of
	// the verification mail and is forwarded verbatim to DeliverLinkToken, the way
	// the reference passes it as sendVerificationEmail's fourth argument
	// (auth.router.ts:1530-1535).
	EmailLang   string
	AccessToken string
	// Origin and Referer resolve which origin the verification link points at, as
	// the reference's resolveSiteUrl(req, config, allowedOrigins) does
	// (auth.router.ts:1528). A multi-origin deployment must email a link back to
	// the origin the request came from, not always to the default one.
	Origin  string
	Referer string
	// APIPrefix is the mount prefix, needed to build the verification link.
	APIPrefix string
}

LinkRequestInput is POST <prefix>/link-request.

type LinkRequestResult added in v0.2.0

type LinkRequestResult struct {
	Token string
	URL   string
}

LinkRequestResult reports what was issued. The token never reaches the HTTP response; it is returned so an embedder driving the service directly can send it themselves.

type LinkTokenDelivery added in v0.2.0

type LinkTokenDelivery struct {
	Email    string
	Provider string
	Token    string
	URL      string
	// EmailLang is the /link-request body's emailLang, passed through untouched so
	// the host's mail transport can localise. Empty when the caller omitted it.
	EmailLang string
}

LinkTokenDelivery is the payload handed to OAuthWiring.DeliverLinkToken. The four members are the reference's sendVerificationEmail(email, token, link, emailLang) arguments, plus the provider the link records.

type LinkVerifyInput added in v0.2.0

type LinkVerifyInput struct {
	Token             string
	LoginAfterLinking bool
}

LinkVerifyInput is POST <prefix>/link-verify.

type LinkVerifyResult added in v0.2.0

type LinkVerifyResult struct {
	User     User
	Tokens   AuthTokens
	LoggedIn bool
}

LinkVerifyResult carries the tokens when loginAfterLinking was asked for.

type LinkedAccountStore

type LinkedAccountStore interface {
	Save(ctx context.Context, link OAuthLinkedAccount) error
	FindByProvider(ctx context.Context, provider, providerID string) (OAuthLinkedAccount, error)
	ListForUser(ctx context.Context, userID string) ([]OAuthLinkedAccount, error)
	Delete(ctx context.Context, id string) error
}

LinkedAccountStore persists OAuth provider associations per user.

type LoginInput

type LoginInput struct {
	Email    string
	Password string
	TenantID string
}

LoginInput is the payload used for user authentication.

type LoginResult added in v0.3.0

type LoginResult struct {
	// User is whose password verified. It is populated on both outcomes, because
	// on both outcomes the password did verify and a host that audit-logs a
	// challenge has to know whose it was.
	User User
	// Tokens is the issued session. It is zero when Challenge is set: a challenge
	// is not a login, and no session exists until the second factor lands.
	Tokens AuthTokens
	// Challenge is nil for a login that completed.
	Challenge *TwoFactorChallenge
}

LoginResult is the outcome of a password login: either a session, or a second-factor challenge.

type MCPServer

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

MCPServer exposes auth configuration as MCP tools for AI editors. It speaks JSON-RPC 2.0 over HTTP and implements the Model Context Protocol tools/list and tools/call methods.

func NewMCPServer

func NewMCPServer(authSvc *Service) *MCPServer

NewMCPServer creates an MCP server wrapping an auth service.

func (*MCPServer) ServeHTTP

func (m *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP handles MCP JSON-RPC 2.0 requests (tools/list, tools/call).

type MagicLinkDelivery added in v0.3.0

type MagicLinkDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	Email    string `json:"email"`
	// Token is the plaintext token the matching verify route accepts.
	Token string `json:"token"`
	// ExpiresAt is the stored expiry, Config.MagicLinkTTL from now. Verification
	// additionally allows Config.ClockSkew past it, so a message that quotes this
	// value understates the window slightly rather than overstating it.
	ExpiresAt time.Time `json:"expiresAt"`
	// LinkBase is the base the link is built under for this request —
	// "https://app.example.com/auth" — resolved by the adapter from the request's
	// Origin or Referer against Config.SiteURLs (Auth.ResolveSiteURL, then
	// HTTPConfig.LinkBase), which is the reference's siteUrlOverride argument
	// (auth.router.ts:1104/1114 → magic-link.strategy.ts:25). It wins over
	// MagicLinkMailer.BaseURL when set; empty when no site URL is configured or
	// the service was called directly, in which case that static base applies.
	LinkBase string `json:"linkBase,omitempty"`
	// Lang is the request's emailLang body field, passed through untouched
	// (auth.router.ts:1080, 1104/1114). "it" and "en" select the built-in
	// template set over MagicLinkMailer.Locale; anything else, the empty string
	// included, defers to it (resolveLang, mailer.service.ts:255-259).
	Lang string `json:"lang,omitempty"`
}

MagicLinkDelivery is what a MagicLinkSender is handed.

It carries the plaintext magic-link token — the store only ever sees its hash — and deliberately nothing else that is credential material. A sender is host code that may log or forward what it receives, so it is given the one secret it has to transmit and no other: no password hash, no TOTP secret, no User. A sender that needs more about the recipient has UserID and TenantID to read it back with.

The JSON tags — on this struct and on the four others a sender is handed — are the shape DeliveryWebhook posts (delivery_webhook.go). Nothing else in this package serialises a delivery.

type MagicLinkMailer added in v0.3.0

type MagicLinkMailer struct {
	// Transport delivers the rendered message. Required.
	Transport MailerTransport
	// BaseURL is what MagicLinkURL builds the link under when the delivery
	// carries no LinkBase of its own: the static fallback for a deployment with
	// no Config.SiteURLs, or for a service called directly. A delivery's
	// LinkBase — the base the adapter resolved for the request — wins over it.
	BaseURL string
	// Locale selects the built-in template set: "en" (the default) or "it". An
	// unknown locale falls back to English, as MailTemplater.Render does.
	//
	// It is the default, not the rule: a delivery whose Lang is "it" or "en" —
	// the request's emailLang body field — renders in that language instead,
	// and any other Lang defers to this (resolveLang, mailer.service.ts:255-259,
	// where Locale plays config.defaultLang).
	Locale string
	// Templates renders subject and bodies. NewMagicLinkMailer fills this in;
	// set Templates.Store to render from a TemplateStore outside a service call
	// (inside one, Config.Templates is found on its own).
	Templates *MailTemplater
}

MagicLinkMailer is the ready-made MagicLinkSender: it renders the magic-link template (TemplateMagicLink — the built-in, or a TemplateStore's override) and hands the result to a MailerTransport, text alternative included. It is the port's equivalent of the reference's config.email.mailer path, where a deployment configures a transport instead of writing a callback (mailer.service.ts:190-193).

Use it as a sender through its Send method:

mailer, err := auth.NewGatewayMailerTransport(auth.MailerConfig{
	Endpoint: endpoint, APIKey: apiKey, From: "noreply@example.com",
})
// handle err
auth.WithMagicLinkSender(auth.NewMagicLinkMailer(
	mailer,
	"Example App",
	"https://app.example.com/auth",
).Send)

func NewMagicLinkMailer added in v0.3.0

func NewMagicLinkMailer(transport MailerTransport, appName, baseURL string) *MagicLinkMailer

NewMagicLinkMailer builds a MagicLinkMailer with the built-in templates. appName is what a template mentioning {{.appName}} shows; the built-ins mention none.

func (*MagicLinkMailer) Send added in v0.3.0

func (m *MagicLinkMailer) Send(ctx context.Context, delivery MagicLinkDelivery) error

Send renders and delivers the magic-link mail. Its signature is MagicLinkSender's.

type MagicLinkSendInput

type MagicLinkSendInput struct {
	Email    string
	TenantID string
	// LinkBase and Lang are what ForgotPasswordInput's are, for
	// MagicLinkDelivery (auth.router.ts:1104, 1114).
	LinkBase string
	Lang     string
}

type MagicLinkSender added in v0.3.0

type MagicLinkSender func(ctx context.Context, delivery MagicLinkDelivery) error

MagicLinkSender delivers a magic link. An error fails the route with the reference's generic 500 — a transport failure must not describe itself to the caller — and leaves the stored token in place, which is what the reference does too: an undelivered token is unguessable and expires on its own.

type MagicLinkStore

type MagicLinkStore interface {
	UpdateMagicLinkToken(ctx context.Context, userID, tenantID, tokenHash string, expiry time.Time) error
	GetUserByMagicLinkTokenHash(ctx context.Context, tokenHash string) (User, error)
	ClearMagicLinkToken(ctx context.Context, userID, tenantID string) error
}

MagicLinkStore persists magic link state.

type MagicLinkVerifyInput

type MagicLinkVerifyInput struct {
	Token string
}

type MailMessage

type MailMessage struct {
	To      string
	Subject string
	// Body is the message: HTML when IsHTML is set, plain text otherwise.
	Body   string
	IsHTML bool
	// Text is the plain-text alternative of an HTML Body — the reference's
	// `text` payload field (mailer.service.ts:133-141). It is read only when
	// IsHTML is set; a plain-text Body already is the text. Left empty, the
	// gateway transport sends the HTML in its place, as the reference's
	// sendCustom does (`text ?? html`, mailer.service.ts:246).
	//
	// omitempty keeps the deprecated HTTPMailerTransport's body byte-identical
	// for the callers that never set it.
	Text string `json:",omitempty"`
}

MailMessage is a single email to be sent.

type MailRendered added in v0.4.0

type MailRendered struct {
	Subject string
	HTML    string
	Text    string
}

MailRendered is a rendered mail: what RenderMail returns and what a ready-made mailer hands its transport as Subject, Body (the HTML) and Text.

type MailTemplate added in v0.4.0

type MailTemplate struct {
	// ID is one of the six reference ids — TemplatePasswordReset and its
	// siblings — or any id of the deployment's own.
	ID       string `json:"id"`
	BaseHTML string `json:"baseHtml"`
	BaseText string `json:"baseText"`
	// Translations is lang -> key -> value. The key "subject" is the mail's
	// subject; every other key is what {{T.key}} resolves to.
	Translations map[string]map[string]string `json:"translations"`
}

MailTemplate is a stored mail template (template-store.interface.ts:1-6). The JSON tags are the reference's key names.

BaseHTML and BaseText are the bodies, with two kinds of placeholder: {{T.key}} is looked up in Translations for the rendering language, and {{key}} is looked up in the data the mailer supplies — link, token, newEmail and the rest; see MailTemplater.RenderMail for the rule and the keys. A template is only used when both bodies are non-empty (mailer.service.ts:158); until then the built-in one keeps rendering.

Placeholders are substituted as they are, in BaseHTML included: no HTML escaping, as the reference does none (mailer.service.ts:163-167). BaseHTML is the deployment's own markup; the values are not all its own — newEmail is the address a user typed, lowercased and trimmed, and an Extra entry is whatever the caller passed — and a stored template that shows them mails them verbatim, exactly as the reference's does. The built-in templates, rendered by html/template, escape them.

type MailTemplateData

type MailTemplateData struct {
	AppName  string
	UserName string
	Token    string
	URL      string
	Code     string
	// NewEmail and OldEmail are the two addresses of the email-changed notice.
	NewEmail string
	OldEmail string
	Extra    map[string]any
}

MailTemplateData holds the variables a template is rendered with.

A built-in template, or one added with Register, reads them as Go template fields — {{.link}}, {{.token}} … — and a stored template reads the same keys as {{link}}, {{token}} … (see RenderMail). The keys are the reference's, so a stored template written for it renders here unchanged:

token     Token                  (mailer.service.ts:186, 191, 201)
link      URL, for every id but welcome           (:186, 191, 201, 211)
loginUrl  URL, for welcome only — the name its template reads (:62); the
          reference's router passes {loginUrl} there (auth.router.ts:724)
newEmail  NewEmail               (:206)
…         every Extra entry under its own key, formatted with fmt.Sprint;
          the welcome template's tempPassword (:63) travels here
appName   AppName, the templater's, when it has one  (a key of this port's)
name      UserName                                   (a key of this port's)
code      Code                                       (a key of this port's)
oldEmail  OldEmail                                   (a key of this port's)

An empty field is left out rather than set to "", so a stored template naming a key the caller did not supply shows the reference's "[key]" placeholder. The reference never passes appName, name, code or oldEmail, and they are absent here too until set, so a stored template written for it sees exactly the keys it sees there: {link, token} for password-reset, magic-link and verify-email (:186, :191, :201), {link} for invitation (:211), {newEmail} for email-changed (:206), {loginUrl, tempPassword} for welcome (auth.router.ts:724). The ready-made mailers supply what the reference's senders do and nothing else. A field set on the struct wins over an Extra entry of the same key.

type MailTemplatePatch added in v0.4.0

type MailTemplatePatch struct {
	BaseHTML     *string
	BaseText     *string
	Translations map[string]map[string]string
}

MailTemplatePatch is what TemplateStore.UpdateMailTemplate applies: the reference's Partial<MailTemplate> (template-store.interface.ts:27), where a field that is present replaces the stored one and a field that is absent leaves it alone — the shallow spread of memory-template.store.ts:22, {...existing, ...template}.

So a nil BaseHTML or BaseText keeps the stored body, and a pointer to "" clears it. A nil Translations keeps every language; a non-nil one replaces the whole lang -> key -> value map — languages it does not name are dropped, as the reference drops them, because translations is one field of the partial and not a map the store merges into. A caller that wants to change one language reads the template first and sends the map back whole.

type MailTemplater

type MailTemplater struct {
	// Store is the template store RenderMail consults. Nil means the one the
	// service put on the context — Config.Templates, set with WithTemplateStore,
	// which reaches every sender the service calls — and, when there is none
	// there either, the built-ins only. Set it to render from a store outside a
	// service call, or from a store other than the configured one.
	Store TemplateStore
	// contains filtered or unexported fields
}

MailTemplater renders localized mail templates: the reference's six built-ins in en and it, any the host registers over them, and — when a TemplateStore is reachable — the stored override for an id.

It is the port's MailerService.render (mailer.service.ts:149-181): the store is consulted first and used when it holds both bodies; otherwise the registered template renders. Register is meant for set-up time; RenderMail and Render are safe to call concurrently, with each other and with Register.

func NewMailTemplater

func NewMailTemplater(appName string) *MailTemplater

NewMailTemplater creates a templater with the built-in en and it templates for the six reference ids. appName is what a template mentioning {{.appName}} shows; the built-ins mention none, as the reference's do not, and it is carried as the appName data key for stored and registered ones.

func (*MailTemplater) Register added in v0.4.0

func (t *MailTemplater) Register(locale, id, subject, html, text string)

Register adds or replaces the template rendered for id in locale. The three parts are Go templates over the keys MailTemplateData lists — {{.link}}, {{.appName}} … — html/template for html, text/template for subject and text. It is what NewMailTemplater fills the built-ins in with, and the way a host replaces one, or adds a locale or an id of its own, without a TemplateStore. An alias id registers under the id it is an alias of.

A part that does not parse is kept and reported by RenderMail for that (locale, id) rather than dropped: a typo must surface, and Register has no error return to surface it through.

func (*MailTemplater) Render

func (t *MailTemplater) Render(locale, name string, data MailTemplateData) (subject, body string, err error)

Render produces the subject and HTML body for the given locale and template id: RenderMail without a context and without the text body, kept for the callers that want exactly that. The store the service configured is only reachable through the context, so a Render outside a service call sees Store and the built-ins.

func (*MailTemplater) RenderMail added in v0.4.0

func (t *MailTemplater) RenderMail(ctx context.Context, locale, id string, data MailTemplateData) (MailRendered, error)

RenderMail renders template id in locale: subject, HTML body and text body.

It is the reference's MailerService.render (mailer.service.ts:149-181):

  • A stored template is looked up first — through Store, or the store the service put on ctx (Config.Templates) — and used only when both its BaseHTML and BaseText are non-empty (:158). A store error is returned.
  • Its translations are those for locale, else for "en", else none (:159). Every {{T.key}} becomes translations[key], or "[key]" when that is missing or empty (:163); then every remaining {{key}} becomes the data value for key — the keys MailTemplateData lists, which for a template written against the reference are exactly the ones it passes — or "[key]" when there is none (:165-168). The subject is translations["subject"], else the registered subject for the language, interpolated the same way (:172-177). Values are substituted as they are, in the HTML body included — no HTML escaping, because the reference does none (:167). A stored BaseHTML is the deployment's own markup, but not every value is: newEmail is the address the user typed on /change-email/request, lowercased and trimmed and nothing more, and an Extra entry is whatever the caller put there. A stored template that shows them mails them verbatim, as the reference's does; the built-ins, rendered by html/template, escape them.
  • Otherwise the registered template for locale renders — the built-in one unless Register replaced it — falling back to "en" when locale has none, with html/template's contextual escaping in the HTML body.

locale is used as given; the ready-made mailers pass what resolveLang picked. The four pre-reference ids are accepted as aliases (see templateAliases). An id with neither a usable stored template nor a registered one is an error.

type MailerConfig added in v0.4.0

type MailerConfig struct {
	// Endpoint is the full URL the gateway accepts a POST at
	// (auth-config.model.ts:20-21). It must be an absolute http or https URL;
	// NewGatewayMailerTransport refuses anything else.
	Endpoint string `json:"endpoint"`
	// APIKey travels in the X-API-Key header (auth-config.model.ts:22-23).
	APIKey string `json:"apiKey"`
	// From is the sender address, sent as `from` (auth-config.model.ts:24-25).
	From string `json:"from"`
	// FromName is the sender's display name, sent as `fromName` when set
	// (auth-config.model.ts:26-27).
	FromName string `json:"fromName,omitempty"`
	// Provider names the gateway-side provider, sent as `provider` when set
	// (auth-config.model.ts:28-29).
	Provider string `json:"provider,omitempty"`
	// DefaultLang is the reference's default template language, "en" or "it"
	// (auth-config.model.ts:30-35; read by resolveLang, mailer.service.ts:255-259).
	// The transport does not read it: which template set a mail is rendered
	// from is the mailers' decision (MagicLinkMailer.Locale and its siblings).
	// It is carried so the block decodes as one unit and can be passed on.
	DefaultLang string `json:"defaultLang,omitempty"`
	// Client makes the request. Nil means a client with a 10-second timeout,
	// the same bound HTTPMailerTransport and HTTPSMSTransport use.
	Client *http.Client `json:"-"`
}

MailerConfig is the reference's email.mailer block (awesome-node-auth src/models/auth-config.model.ts:19-36): the settings of the mail gateway NewGatewayMailerTransport talks to. The JSON tags are the reference's key names, so a deployment that already holds that block for the reference can decode it and hand it over as is.

type MailerTransport

type MailerTransport interface {
	Send(ctx context.Context, msg MailMessage) error
}

MailerTransport delivers email messages.

func NewGatewayMailerTransport added in v0.4.0

func NewGatewayMailerTransport(cfg MailerConfig) (MailerTransport, error)

NewGatewayMailerTransport builds the transport for the family's mail gateway, the contract the reference's built-in mailer speaks (awesome-node-auth src/services/mailer.service.ts:261-291):

POST {endpoint}
Content-Type: application/json
X-API-Key: {apiKey}

{"to": …, "subject": …, "html": …, "text": …, "from": …, "fromName": …, "provider": …}

Any status outside 2xx is a failure (mailer.service.ts:280-284). The endpoint's own path and query are kept, as the reference keeps them (mailer.service.ts:269).

It fails when cfg.Endpoint is empty or not an absolute http(s) URL, so that a misconfiguration surfaces at start-up rather than as a 500 on the first send. The reference has no such check — `new URL(endpoint)` throws inside send — so the constructor is the port's only addition, and it adds no request.

type MemoryAuthCodeStore added in v0.4.0

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

MemoryAuthCodeStore is an in-process implementation of AuthCodeStore. It is the IDP's default and is correct only while /authorize and /token are served by the same process; see IDPConfig.Codes.

A single mutex covers both methods, so ConsumeCode's lookup-and-delete is atomic and exactly one of any number of concurrent consumers receives the record. Expired entries are dropped on the next SaveCode so a flow the user abandoned does not pin memory until restart.

func NewMemoryAuthCodeStore added in v0.4.0

func NewMemoryAuthCodeStore() *MemoryAuthCodeStore

func (*MemoryAuthCodeStore) ConsumeCode added in v0.4.0

func (s *MemoryAuthCodeStore) ConsumeCode(_ context.Context, codeHash string) (AuthCode, error)

func (*MemoryAuthCodeStore) SaveCode added in v0.4.0

func (s *MemoryAuthCodeStore) SaveCode(_ context.Context, code AuthCode) error

type MemoryLinkedAccounts

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

MemoryLinkedAccounts is an in-memory implementation of LinkedAccountStore, suitable for development, testing, and embedded deployments.

func NewMemoryLinkedAccounts

func NewMemoryLinkedAccounts() *MemoryLinkedAccounts

NewMemoryLinkedAccounts creates an in-memory LinkedAccountStore.

func (*MemoryLinkedAccounts) Delete

func (m *MemoryLinkedAccounts) Delete(_ context.Context, id string) error

func (*MemoryLinkedAccounts) FindByProvider

func (m *MemoryLinkedAccounts) FindByProvider(_ context.Context, provider, providerID string) (OAuthLinkedAccount, error)

func (*MemoryLinkedAccounts) ListForUser

func (m *MemoryLinkedAccounts) ListForUser(_ context.Context, userID string) ([]OAuthLinkedAccount, error)

func (*MemoryLinkedAccounts) Save

type MemoryMetadataStore

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

MemoryMetadataStore is an in-memory implementation of UserMetadataStore.

func NewMemoryMetadataStore

func NewMemoryMetadataStore() *MemoryMetadataStore

func (*MemoryMetadataStore) ClearMetadata

func (s *MemoryMetadataStore) ClearMetadata(_ context.Context, userID string) error

func (*MemoryMetadataStore) GetMetadata

func (s *MemoryMetadataStore) GetMetadata(_ context.Context, userID string) (map[string]any, error)

func (*MemoryMetadataStore) UpdateMetadata

func (s *MemoryMetadataStore) UpdateMetadata(_ context.Context, userID string, metadata map[string]any) error
type MemoryPendingLinks struct {
	// contains filtered or unexported fields
}

MemoryPendingLinks is an in-memory PendingLinkStore, the counterpart of MemoryLinkedAccounts. It enforces the ttl, which is what makes an OAuth state nonce single-use and an account-link token expire.

func NewMemoryPendingLinks() *MemoryPendingLinks

NewMemoryPendingLinks creates an in-memory PendingLinkStore.

func (*MemoryPendingLinks) Delete added in v0.2.0

func (m *MemoryPendingLinks) Delete(_ context.Context, key string) error

func (*MemoryPendingLinks) Get added in v0.2.0

func (*MemoryPendingLinks) Save added in v0.2.0

type MemoryRolesPermissionsStore

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

MemoryRolesPermissionsStore is an in-memory RBAC store.

func NewMemoryRolesPermissionsStore

func NewMemoryRolesPermissionsStore() *MemoryRolesPermissionsStore

func (*MemoryRolesPermissionsStore) AddPermissionToRole

func (s *MemoryRolesPermissionsStore) AddPermissionToRole(_ context.Context, role, permission string) error

func (*MemoryRolesPermissionsStore) AddRoleToUser

func (s *MemoryRolesPermissionsStore) AddRoleToUser(_ context.Context, userID, role string, tenantID string) error

func (*MemoryRolesPermissionsStore) CreateRole

func (s *MemoryRolesPermissionsStore) CreateRole(_ context.Context, role string, permissions []string) error

func (*MemoryRolesPermissionsStore) DeleteRole

func (s *MemoryRolesPermissionsStore) DeleteRole(_ context.Context, role string) error

func (*MemoryRolesPermissionsStore) GetPermissionsForRole

func (s *MemoryRolesPermissionsStore) GetPermissionsForRole(_ context.Context, role string) ([]string, error)

func (*MemoryRolesPermissionsStore) GetPermissionsForUser

func (s *MemoryRolesPermissionsStore) GetPermissionsForUser(ctx context.Context, userID, tenantID string) ([]string, error)

func (*MemoryRolesPermissionsStore) GetRolesForUser

func (s *MemoryRolesPermissionsStore) GetRolesForUser(_ context.Context, userID, tenantID string) ([]string, error)

func (*MemoryRolesPermissionsStore) RemovePermissionFromRole

func (s *MemoryRolesPermissionsStore) RemovePermissionFromRole(_ context.Context, role, permission string) error

func (*MemoryRolesPermissionsStore) RemoveRoleFromUser

func (s *MemoryRolesPermissionsStore) RemoveRoleFromUser(_ context.Context, userID, role string, tenantID string) error

func (*MemoryRolesPermissionsStore) UserHasPermission

func (s *MemoryRolesPermissionsStore) UserHasPermission(ctx context.Context, userID, permission, tenantID string) (bool, error)

type MemorySessionStore

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

MemorySessionStore is a thread-safe in-memory implementation of SessionStore.

func NewMemorySessionStore

func NewMemorySessionStore() *MemorySessionStore

func (*MemorySessionStore) CreateSession

func (s *MemorySessionStore) CreateSession(_ context.Context, session Session) (Session, error)

func (*MemorySessionStore) DeleteExpiredSessions

func (s *MemorySessionStore) DeleteExpiredSessions(_ context.Context, now time.Time) (int, error)

func (*MemorySessionStore) GetSessionByID

func (s *MemorySessionStore) GetSessionByID(_ context.Context, sessionID string) (Session, error)

func (*MemorySessionStore) GetSessionByRefreshTokenHash

func (s *MemorySessionStore) GetSessionByRefreshTokenHash(_ context.Context, tokenHash string) (Session, error)

func (*MemorySessionStore) ListSessionsForUser

func (s *MemorySessionStore) ListSessionsForUser(_ context.Context, userID, tenantID string) ([]Session, error)

func (*MemorySessionStore) RevokeSessionByID

func (s *MemorySessionStore) RevokeSessionByID(_ context.Context, sessionID string) error

func (*MemorySessionStore) UpdateSession

func (s *MemorySessionStore) UpdateSession(_ context.Context, session Session) error

type MemorySettingsStore added in v0.6.0

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

MemorySettingsStore is the in-memory SettingsStore, safe for concurrent use. Everything handed out is a copy, so mutating a returned AuthSettings — or the UISettings behind it — does not reach the store.

func NewMemorySettingsStore added in v0.6.0

func NewMemorySettingsStore() *MemorySettingsStore

NewMemorySettingsStore returns a MemorySettingsStore holding nothing, so that GetSettings answers the zero AuthSettings until something is written.

func (*MemorySettingsStore) GetSettings added in v0.6.0

func (s *MemorySettingsStore) GetSettings(_ context.Context) (AuthSettings, error)

GetSettings returns a copy of the stored settings.

func (*MemorySettingsStore) UpdateSettings added in v0.6.0

func (s *MemorySettingsStore) UpdateSettings(_ context.Context, patch AuthSettings) (AuthSettings, error)

UpdateSettings applies patch with MergeSettings and returns a copy of what is stored afterwards.

type MemoryTelemetryStore

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

MemoryTelemetryStore is an in-memory implementation of TelemetryStore.

func NewMemoryTelemetryStore

func NewMemoryTelemetryStore() *MemoryTelemetryStore

NewMemoryTelemetryStore creates an in-memory telemetry store.

func (*MemoryTelemetryStore) Query

func (*MemoryTelemetryStore) Record

type MemoryTemplateStore added in v0.4.0

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

MemoryTemplateStore is the in-memory TemplateStore (memory-template.store.ts), safe for concurrent use. Everything handed out is a copy: mutating a returned template does not reach the store. Lists come back in first-insertion order, as the reference's Map iteration does (memory-template.store.ts:11-13, 29-31).

func NewMemoryTemplateStore added in v0.4.0

func NewMemoryTemplateStore() *MemoryTemplateStore

NewMemoryTemplateStore returns an empty MemoryTemplateStore.

func (*MemoryTemplateStore) GetMailTemplate added in v0.4.0

func (s *MemoryTemplateStore) GetMailTemplate(_ context.Context, id string) (MailTemplate, bool, error)

func (*MemoryTemplateStore) GetUITranslations added in v0.4.0

func (s *MemoryTemplateStore) GetUITranslations(_ context.Context, page string) (UITranslation, bool, error)

func (*MemoryTemplateStore) ListMailTemplates added in v0.4.0

func (s *MemoryTemplateStore) ListMailTemplates(_ context.Context) ([]MailTemplate, error)

ListMailTemplates returns every stored template in the order the ids were first written.

func (*MemoryTemplateStore) ListUITranslations added in v0.4.0

func (s *MemoryTemplateStore) ListUITranslations(_ context.Context) ([]UITranslation, error)

ListUITranslations returns every stored page in the order the pages were first written.

func (*MemoryTemplateStore) UpdateMailTemplate added in v0.4.0

func (s *MemoryTemplateStore) UpdateMailTemplate(_ context.Context, id string, patch MailTemplatePatch) (MailTemplate, error)

UpdateMailTemplate upserts: a template the store does not hold starts as {id, "", "", {}} (memory-template.store.ts:16-21) and the patch is spread on top (:22), as MailTemplatePatch describes. The stored result is returned.

func (*MemoryTemplateStore) UpdateUITranslations added in v0.4.0

func (s *MemoryTemplateStore) UpdateUITranslations(_ context.Context, page string, translations map[string]map[string]string) (UITranslation, error)

UpdateUITranslations replaces the page's translations wholesale (memory-template.store.ts:33-35): a page is set, not patched.

type MemoryTenantStore

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

MemoryTenantStore is an in-memory tenant/membership store.

func NewMemoryTenantStore

func NewMemoryTenantStore() *MemoryTenantStore

func (*MemoryTenantStore) AssociateUserWithTenant

func (s *MemoryTenantStore) AssociateUserWithTenant(_ context.Context, userID, tenantID string) error

func (*MemoryTenantStore) CreateTenant

func (s *MemoryTenantStore) CreateTenant(_ context.Context, tenant Tenant) (Tenant, error)

func (*MemoryTenantStore) DeleteTenant

func (s *MemoryTenantStore) DeleteTenant(_ context.Context, id string) error

func (*MemoryTenantStore) DisassociateUserFromTenant

func (s *MemoryTenantStore) DisassociateUserFromTenant(_ context.Context, userID, tenantID string) error

func (*MemoryTenantStore) GetAllTenants

func (s *MemoryTenantStore) GetAllTenants(_ context.Context) ([]Tenant, error)

func (*MemoryTenantStore) GetTenantByID

func (s *MemoryTenantStore) GetTenantByID(_ context.Context, id string) (Tenant, error)

func (*MemoryTenantStore) GetTenantsForUser

func (s *MemoryTenantStore) GetTenantsForUser(_ context.Context, userID string) ([]Tenant, error)

func (*MemoryTenantStore) GetUsersForTenant

func (s *MemoryTenantStore) GetUsersForTenant(_ context.Context, tenantID string) ([]string, error)

func (*MemoryTenantStore) UpdateTenant

func (s *MemoryTenantStore) UpdateTenant(_ context.Context, id string, update Tenant) error

type MemoryUserStore

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

MemoryUserStore is a thread-safe in-memory implementation of UserStore.

func NewMemoryUserStore

func NewMemoryUserStore() *MemoryUserStore

func (*MemoryUserStore) ApplyEmailChange

func (s *MemoryUserStore) ApplyEmailChange(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) ClearEmailChangeToken

func (s *MemoryUserStore) ClearEmailChangeToken(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) ClearEmailVerificationToken

func (s *MemoryUserStore) ClearEmailVerificationToken(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) ClearMagicLinkToken

func (s *MemoryUserStore) ClearMagicLinkToken(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) ClearResetToken

func (s *MemoryUserStore) ClearResetToken(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) ClearSMSCode

func (s *MemoryUserStore) ClearSMSCode(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) CreateUser

func (s *MemoryUserStore) CreateUser(_ context.Context, user User) (User, error)

func (*MemoryUserStore) DeleteUser

func (s *MemoryUserStore) DeleteUser(_ context.Context, userID, tenantID string) error

func (*MemoryUserStore) GetUserByEmail

func (s *MemoryUserStore) GetUserByEmail(_ context.Context, email, tenantID string) (User, error)

func (*MemoryUserStore) GetUserByEmailChangeTokenHash

func (s *MemoryUserStore) GetUserByEmailChangeTokenHash(_ context.Context, tokenHash string) (User, error)

func (*MemoryUserStore) GetUserByEmailVerificationTokenHash

func (s *MemoryUserStore) GetUserByEmailVerificationTokenHash(_ context.Context, tokenHash string) (User, error)

func (*MemoryUserStore) GetUserByID

func (s *MemoryUserStore) GetUserByID(_ context.Context, id, tenantID string) (User, error)

func (*MemoryUserStore) GetUserByMagicLinkTokenHash

func (s *MemoryUserStore) GetUserByMagicLinkTokenHash(_ context.Context, tokenHash string) (User, error)

func (*MemoryUserStore) GetUserByResetTokenHash

func (s *MemoryUserStore) GetUserByResetTokenHash(_ context.Context, tokenHash string) (User, error)

func (*MemoryUserStore) GetUserBySMSCodeHash

func (s *MemoryUserStore) GetUserBySMSCodeHash(_ context.Context, userID, tenantID, codeHash string) (User, error)

func (*MemoryUserStore) MarkEmailVerified

func (s *MemoryUserStore) MarkEmailVerified(_ context.Context, userID, tenantID string, verified bool) error

func (*MemoryUserStore) UpdateEmailChangeToken

func (s *MemoryUserStore) UpdateEmailChangeToken(_ context.Context, userID, tenantID, pendingEmail, tokenHash string, expiry time.Time) error

func (*MemoryUserStore) UpdateEmailVerificationToken

func (s *MemoryUserStore) UpdateEmailVerificationToken(_ context.Context, userID, tenantID, tokenHash string, expiry time.Time) error

func (*MemoryUserStore) UpdateMagicLinkToken

func (s *MemoryUserStore) UpdateMagicLinkToken(_ context.Context, userID, tenantID, tokenHash string, expiry time.Time) error

func (*MemoryUserStore) UpdatePassword

func (s *MemoryUserStore) UpdatePassword(_ context.Context, userID, tenantID, passwordHash string) error

func (*MemoryUserStore) UpdatePhoneNumber added in v0.2.0

func (s *MemoryUserStore) UpdatePhoneNumber(_ context.Context, userID, tenantID, phoneNumber string) (User, error)

UpdatePhoneNumber makes MemoryUserStore satisfy UserPhoneStore. It lives beside the interface rather than in memory_store.go so that the capability and its in-memory implementation stay in one place.

func (*MemoryUserStore) UpdateProfile

func (s *MemoryUserStore) UpdateProfile(_ context.Context, userID, tenantID, firstName, lastName string) (User, error)

func (*MemoryUserStore) UpdateResetToken

func (s *MemoryUserStore) UpdateResetToken(_ context.Context, userID, tenantID, tokenHash string, expiry time.Time) error

func (*MemoryUserStore) UpdateSMSCode

func (s *MemoryUserStore) UpdateSMSCode(_ context.Context, userID, tenantID, codeHash string, expiry time.Time) error

func (*MemoryUserStore) UpdateTOTPSecret

func (s *MemoryUserStore) UpdateTOTPSecret(_ context.Context, userID, tenantID, secret string, enabled bool) error

type OAuthAccountConflictError added in v0.6.0

type OAuthAccountConflictError struct {
	Provider          string
	Email             string
	ProviderAccountID string
}

OAuthAccountConflictError is the outcome the reference's AuthError("...", "OAUTH_ACCOUNT_CONFLICT") carries, with the same two data fields (auth.router.ts:1346-1355): the address the provider asserted and the provider account that asserted it. The callback answers it the way the reference does — it stashes the pair and redirects the browser to /account-conflict — rather than as a JSON error, which is why nothing maps it in OAuthHTTPError.

It is raised only under OnEmailMatch = OAuthEmailMatchConflict: a provider account nobody has linked, asserting an address some account already holds.

func (*OAuthAccountConflictError) Error added in v0.6.0

func (e *OAuthAccountConflictError) Error() string

func (*OAuthAccountConflictError) Unwrap added in v0.6.0

func (e *OAuthAccountConflictError) Unwrap() error

type OAuthBeginInput added in v0.2.0

type OAuthBeginInput struct {
	Provider   string
	ReturnPath string
	Origin     string
	Referer    string
}

OAuthBeginInput is GET <prefix>/oauth/{provider}.

There is deliberately no "link to the caller's account" field. The reference's /oauth/{provider} has no auth gate and reads no credential (wire-contract.md §4: "Auth gate: none"); the intent is not carried in the state either, and the callback resolves the identity purely through the provider account. Reading the initiating request's access token and linking the provider identity to it is an account-takeover shape: whoever happened to be signed in when the flow started would get the session, and the (provider, providerAccountId) binding would be re-pointed at them. Linking an existing account to a provider goes through /link-request and /link-verify, where an emailed token proves the address.

type OAuthBeginResult added in v0.2.0

type OAuthBeginResult struct {
	AuthorizationURL string
	State            string
}

OAuthBeginResult is what the route redirects to.

type OAuthCompleteInput added in v0.2.0

type OAuthCompleteInput struct {
	Provider string
	Code     string
	State    string
}

OAuthCompleteInput is GET <prefix>/oauth/{provider}/callback.

type OAuthCompleteResult added in v0.2.0

type OAuthCompleteResult struct {
	User       User
	Tokens     AuthTokens
	RedirectTo string
}

OAuthCompleteResult carries the issued session and where to send the browser.

type OAuthLinkedAccount

type OAuthLinkedAccount struct {
	ID         string
	UserID     string
	Provider   string
	ProviderID string
	Email      string
	Name       string
	Picture    string
	CreatedAt  time.Time
}

OAuthLinkedAccount stores a linked provider<->user association.

Email, Name and Picture are the reference's optional LinkedAccount profile columns (linked-accounts-store.interface.ts:9-14). GET /linked-accounts projects all three, and clients render them: the family admin UI falls back through `a.name || a.email || a.providerAccountId`. The library fills Email at every link it writes, as the reference's linkAccount calls do; Name and Picture are left for a host store that wants to keep them, again as in the reference.

type OAuthPendingMeta

type OAuthPendingMeta struct {
	Provider    string
	RedirectURL string
	TenantID    string
	UserID      string // non-empty when linking to existing account
	// Email and ProviderAccountID carry the account-linking stash: the address
	// a /link-request token was issued for, and the provider account a callback
	// conflict parked for it.
	Email             string
	ProviderAccountID string
	// ExpiresAt lets a consumer enforce the deadline itself rather than trusting
	// the store to have honoured the ttl argument.
	ExpiresAt time.Time
}

OAuthPendingMeta is the metadata stored during OAuth flow.

type OAuthProvider

type OAuthProvider struct {
	Name         string
	ClientID     string
	ClientSecret string
	RedirectURL  string
	AuthURL      string
	TokenURL     string
	UserInfoURL  string
	Scopes       []string
	// AdditionalAuthParams are extra query parameters for the authorization
	// URL — provider-specific knobs such as Google's access_type or prompt. They
	// are the reference's additionalAuthParams (generic-oauth.strategy.ts:63) and
	// keep its precedence exactly (:113-120): an entry overrides client_id,
	// redirect_uri, response_type and scope, and state overrides an entry. The
	// PKCE pair this port adds (code_challenge, code_challenge_method) is written
	// after them as well, so a configuration cannot weaken the code binding.
	AdditionalAuthParams map[string]string
	// GitHubEmailsURL is the endpoint the "github" provider consults when the
	// user profile carries no email — a GitHub account whose address is private
	// (github.strategy.ts:54-68). Empty means UserInfoURL + "/emails", which is
	// https://api.github.com/user/emails for the preset; a test points it at a
	// fake. Only the "github" provider reads it.
	GitHubEmailsURL string
	// MapProfile replaces the default mapping of the userinfo document for
	// this provider — the reference's mapProfile hook
	// (generic-oauth.strategy.ts:71-77), which getUserProfile returns as the
	// profile whenever it is set (:151-153). It replaces the whole mapping,
	// the github preset's /user/emails fallback included. The service fills
	// Provider, and Raw when the hook left it nil; an error fails the exchange
	// on the profile-failed path (401 OAUTH_PROFILE_FAILED on the wire). When
	// both MapProfile and ProfileMap are set, MapProfile wins.
	MapProfile func(raw map[string]any) (OAuthUserInfo, error)
	// ProfileMap is the declarative form of MapProfile for a provider loaded
	// from configuration: the keys id, email, emailVerified, name and picture
	// (id required) mapped to expressions such as "$.data.user.id" or
	// "$.mail ?? $.userPrincipalName". CompileProfileMap documents the grammar.
	// NewOAuthService compiles it: a map that does not compile is recorded
	// against the provider, whose exchanges then fail with that error before
	// contacting the provider (500 on the wire); NewOAuthServiceWithConfig
	// reports it at start-up instead. nil or empty means the default mapping.
	ProfileMap map[string]string
}

OAuthProvider holds configuration for a single OAuth 2.0 provider. It is the reference's GenericOAuthProviderConfig (generic-oauth.strategy.ts:39-78) with the two hard-coded strategies, Google and GitHub, expressed as presets.

func GitHubProvider

func GitHubProvider(clientID, clientSecret, redirectURL string) OAuthProvider

GitHubProvider builds a pre-configured GitHub OAuth2 provider — the reference's GithubStrategy (github.strategy.ts:23-31, :49-70), including the /user/emails fallback for accounts with a private address.

func GoogleProvider

func GoogleProvider(clientID, clientSecret, redirectURL string) OAuthProvider

GoogleProvider builds a pre-configured Google OAuth2 provider — the reference's GoogleStrategy (google.strategy.ts:23-33, :53). The authorization request carries access_type=offline (:29), the one extra parameter that strategy sends; it sends no prompt.

type OAuthProvisioning added in v0.6.0

type OAuthProvisioning struct {
	// AutoCreate allows the callback to create an account for a provider
	// identity nothing here knows yet. With it false the callback answers
	// 403 OAUTH_USER_NOT_PROVISIONED instead, so accounts can only be made by
	// some other path (an invitation, an admin, /register).
	AutoCreate bool
	// AllowedEmailDomains restricts which addresses may sign in through any
	// provider. Empty allows every address. An entry is a bare domain —
	// "example.com", or "@example.com", which is accepted and normalised —
	// matched case-insensitively against the part of the profile address after
	// the last @, with no subdomain matching: "example.com" does not admit
	// "mail.example.com". A profile with no address at all is refused whenever
	// the list is non-empty, because nothing about it can be checked.
	AllowedEmailDomains []string
	// RequireVerifiedEmail demands that the provider actually asserted the
	// address. A profile whose emailVerified is false — and one where the
	// provider said nothing, which is most of them — is refused with
	// 403 OAUTH_EMAIL_NOT_VERIFIED. It is off by default: turning it on for a
	// provider that does not send the claim refuses every login through it.
	RequireVerifiedEmail bool
	// OnEmailMatch decides what happens when the provider account is unknown
	// but some account already holds the profile address:
	// OAuthEmailMatchLink (the default), OAuthEmailMatchConflict or
	// OAuthEmailMatchReject.
	OnEmailMatch string
	// FieldMap fills further User fields from the raw provider profile when the
	// callback creates an account. The keys are the fields it may write —
	// firstName, lastName, phoneNumber and role — and the values are
	// expressions in the ProfileMap grammar, evaluated against the same
	// userinfo document (see CompileProfileMap): "$.given_name",
	// "$.name.first ?? $.given_name", "$.roles[0]", or a quoted literal as the
	// last resort. A key outside that list, or a value that does not compile,
	// is refused by Validate and therefore by WithOAuth, so a typo in
	// configuration fails at start-up rather than at the first login. An
	// expression that resolves to nothing leaves its field alone; one that
	// resolves to an object or an array fails the callback.
	//
	// The fields the profile itself owns are deliberately not targets: id and
	// email come from OAuthUserInfo (map them with ProfileMap), tenantId comes
	// from the wiring, loginProvider is the provider, and isEmailVerified is
	// this policy's business rather than a mapping's.
	FieldMap map[string]string
}

OAuthProvisioning is the declarative replacement for the abstract findOrCreateUser the reference makes every integrator write (generic-oauth.strategy.ts:169-172). It governs the whole callback, not only the create branch: RequireVerifiedEmail and AllowedEmailDomains are checked before the identity is resolved at all, exactly as a findOrCreateUser that began with those checks would.

The zero value is not the default policy — DefaultOAuthProvisioning is, and OAuthWiring.Provisioning is a pointer so that a deployment which sets nothing keeps the behaviour it had before this type existed. A policy given explicitly is taken as written: AutoCreate false means false. The one field whose empty value still means a default is OnEmailMatch, which has no empty mode.

func DefaultOAuthProvisioning added in v0.6.0

func DefaultOAuthProvisioning() OAuthProvisioning

DefaultOAuthProvisioning is the policy a wiring without one gets: create missing accounts, link a matching address. It is what this port did before OAuthProvisioning existed, so adding the type changed no deployment.

func (OAuthProvisioning) Validate added in v0.6.0

func (p OAuthProvisioning) Validate() error

Validate reports what is wrong with a policy loaded from configuration: an unknown OnEmailMatch mode, an empty or malformed domain entry, a FieldMap key that is not a field this policy may write, or a FieldMap expression that does not compile. WithOAuth runs it, so a deployment that wires an invalid policy fails at construction; it is exported so one can check a policy before wiring anything, as CompileProfileMap can be.

type OAuthService

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

OAuthService handles OAuth 2.0 authorization code flow.

func NewOAuthService

func NewOAuthService(providers ...OAuthProvider) *OAuthService

NewOAuthService creates an OAuthService for the given providers. A ProfileMap that does not compile is not fatal here: the error is recorded and every exchange through that provider returns it, while the other providers keep working. NewOAuthServiceWithConfig surfaces it at start-up.

func NewOAuthServiceWithConfig added in v0.4.0

func NewOAuthServiceWithConfig(providers ...OAuthProvider) (*OAuthService, error)

NewOAuthServiceWithConfig is NewOAuthService with the configuration validated: it returns the ProfileMap compile errors of every provider that has one — joined, each naming its provider and the expression — and no service, so a deployment fails at start-up rather than at the first login.

func (*OAuthService) AuthorizeURL

func (s *OAuthService) AuthorizeURL(providerName, state string) (string, error)

AuthorizeURL builds the redirect URL for the given provider and state token.

func (*OAuthService) AuthorizeURLPKCE added in v0.2.0

func (s *OAuthService) AuthorizeURLPKCE(providerName, state, codeChallenge string) (string, error)

AuthorizeURLPKCE builds the redirect URL and, when codeChallenge is non-empty, binds the exchange to it with the S256 method (RFC 7636). The HTTP routes always pass a challenge; the plain AuthorizeURL stays for embedders driving the service themselves.

func (*OAuthService) ExchangeCode

func (s *OAuthService) ExchangeCode(ctx context.Context, providerName, code string) (OAuthUserInfo, error)

ExchangeCode exchanges an authorization code for user info.

func (*OAuthService) ExchangeCodePKCE added in v0.2.0

func (s *OAuthService) ExchangeCodePKCE(ctx context.Context, providerName, code, codeVerifier string) (OAuthUserInfo, error)

ExchangeCodePKCE exchanges an authorization code, sending the PKCE verifier when one was used to build the authorization URL.

func (*OAuthService) HandleCallback

func (s *OAuthService) HandleCallback(
	ctx context.Context,
	authSvc *Service,
	linkedAccounts LinkedAccountStore,
	info OAuthUserInfo,
	tenantID string,
	linkToUserID string,
) (User, AuthTokens, error)

HandleCallback resolves/creates a user after OAuth callback under the default provisioning policy (DefaultOAuthProvisioning): accounts are created when the provider identity is unknown, and an address some account already holds is linked to it. It is HandleCallbackWithPolicy with that policy, kept as it was so an embedder driving the service directly is unaffected by the policy.

If linkToUserID is non-empty the provider is linked to that existing account.

func (*OAuthService) HandleCallbackWithPolicy added in v0.6.0

func (s *OAuthService) HandleCallbackWithPolicy(
	ctx context.Context,
	authSvc *Service,
	linkedAccounts LinkedAccountStore,
	info OAuthUserInfo,
	tenantID string,
	linkToUserID string,
	policy OAuthProvisioning,
) (User, AuthTokens, error)

HandleCallbackWithPolicy is HandleCallback under an explicit provisioning policy — the whole of what the reference leaves to the integrator's findOrCreateUser (generic-oauth.strategy.ts:169-172). The order is the reference's resolution order with the policy's terms in it:

  1. The policy gate: the provider must have asserted the address when RequireVerifiedEmail is set, and the address must be in an admitted domain. Both refuse before anything is read or written, so a refused identity leaves no trace.
  2. linkToUserID, when the caller named an account to link to.
  3. The existing (provider, providerAccountId) link, which is the only identification the reference's own store documentation considers safe (user-store.interface.ts:105-119).
  4. An account already holding the address: linked, refused as a conflict or refused outright, per OnEmailMatch.
  5. Creation, when AutoCreate allows it.

The conflict outcome is *OAuthAccountConflictError; the three refusals are this port's own and reach the wire as 403s with the codes in oauth_wire.go.

func (*OAuthService) Has added in v0.2.0

func (s *OAuthService) Has(name string) bool

Has reports whether a provider is registered.

type OAuthUserInfo

type OAuthUserInfo struct {
	ProviderID string
	Provider   string
	Email      string
	Name       string
	AvatarURL  string
	// EmailVerified is the provider's email_verified claim, or for GitHub the
	// verified flag of the address picked from /user/emails. nil means the
	// provider said nothing, the reference's `emailVerified: undefined`. The
	// provisioning policy reads it twice: RequireVerifiedEmail refuses a
	// callback that has no positive claim, and an account the callback creates
	// is verified unless the provider positively said otherwise (D-11).
	EmailVerified *bool
	Raw           map[string]any
}

OAuthUserInfo is the normalized profile returned after token exchange — the reference's {id, email, emailVerified?, name?, picture?} (generic-oauth.strategy.ts:143, :154-160), plus the provider name and the raw document.

type OAuthWiring added in v0.2.0

type OAuthWiring struct {
	// Service resolves providers, builds authorization URLs and exchanges codes.
	Service *OAuthService
	// LinkedAccounts persists the provider ↔ user associations the
	// /linked-accounts routes read and write.
	LinkedAccounts LinkedAccountStore
	// PendingLinks is the stash. It carries two kinds of entry, both keyed by an
	// opaque string and both single-use: the in-flight OAuth state (so a nonce
	// cannot be replayed) and the account-link token issued by /link-request.
	PendingLinks PendingLinkStore
	// AllowedOrigins is the port's spelling of the reference's cors.origins for
	// origin resolution: together with Config.SiteURLs — the two lists merged in
	// that order and deduplicated, as buildAllowedOrigins does
	// (auth.router.ts:213-219) — it is the allowlist an initiating request's
	// Origin or Referer is matched against, and the allowlist a state's origin
	// has to be in to be redirected to. It is the same list Auth.ResolveSiteURL
	// uses for emailed links. An empty merged allowlist accepts the origin
	// embedded in the state, matching the reference — safe here only because
	// the state is signed.
	AllowedOrigins []string
	// SiteURL is the fallback redirect target, the reference's first
	// config.email.siteUrl entry. It predates Config.SiteURLs and stays as the
	// override: when set it is the default site URL for the OAuth routes and for
	// Auth.ResolveSiteURL alike; when empty the first Config.SiteURLs entry is.
	// A deployment configuring only SiteURLs may leave it unset.
	SiteURL string
	// TenantID scopes users created or resolved by the callback.
	TenantID string
	// Provisioning is the policy the callback resolves an identity under — the
	// declarative stand-in for the abstract findOrCreateUser the reference makes
	// every integrator write (generic-oauth.strategy.ts:169-172). It is a
	// pointer so that nil means DefaultOAuthProvisioning: a deployment that
	// leaves it unset keeps exactly the behaviour it had before the policy
	// existed (create missing accounts, link a matching address), and one that
	// sets it is taken at its word, AutoCreate false included. WithOAuth
	// validates it, so an unknown OnEmailMatch or a FieldMap that does not
	// compile fails at construction.
	Provisioning *OAuthProvisioning
	// StateTTL bounds how long a signed state stays acceptable. Default 10m.
	StateTTL time.Duration
	// LinkTokenTTL bounds the /link-request token. Default 1h, as the reference.
	LinkTokenTTL time.Duration
	// DeliverLinkToken hands the /link-request verification link to the host's
	// mail transport. The route answers success either way, as the reference
	// does when no transport is configured.
	DeliverLinkToken func(ctx context.Context, delivery LinkTokenDelivery) error
}

OAuthWiring is everything the OAuth and account-linking routes need beyond the core service. The reference gates each route on the presence of the corresponding option; the equivalent here is that an absent member turns its routes into NOT_IMPLEMENTED rather than leaving them unmounted, because a single dynamic /oauth/{provider} route cannot be registered per provider.

type OIDCMount added in v0.7.0

type OIDCMount struct {
	// Path is the endpoint below the mount prefix, leading "/" included.
	Path string
	// Handler serves it, for every method.
	Handler http.Handler
	// ResourceServerGated reports that this endpoint creates or exchanges a
	// credential, so HTTPConfig.ResourceServer leaves it unmounted the way it
	// leaves /login and /refresh unmounted. Every adapter skips a gated mount
	// under that flag; see oidcResourceServerGated for which two carry it and
	// why. RegisterHandlers ignores the field: it mounts a host's own mux, with
	// no HTTPConfig in sight, so the host decides what goes on it.
	ResourceServerGated bool
}

OIDCMount is one OIDC endpoint as the adapters mount it: Path is below the mount prefix and Handler serves it.

The handler is mounted for every method, which is what (*IDP).RegisterHandlers has always done with a method-less http.ServeMux pattern, and what the handlers expect — /authorize reads r.Method itself to tell its login form from its credential post, /token and /userinfo read the form and the Authorization header rather than the method. So there is no method to narrow to without changing an answer, and the four adapters register the handler for all methods rather than for a list they would then have to keep in step.

type OpenAPIInfo

type OpenAPIInfo struct {
	Title       string
	Description string
	Version     string
	ServerURL   string
	// APIPrefix is where the routes are mounted. Empty means DefaultAPIPrefix,
	// and it must match the HTTPConfig the adapter was mounted with, otherwise
	// the spec documents paths the server does not serve.
	APIPrefix string
	// IDProvider adds the IdP's public JWKS route to the document. Set it when
	// the adapter was mounted on an Auth built WithIDP, and leave it false
	// otherwise: that route exists only under that configuration, so a document
	// that always carried it would advertise an endpoint most deployments
	// answer 404 for.
	IDProvider bool
	// JWKSPath is where that route is served below APIPrefix. Empty means
	// DefaultJWKSPath, and it must match the IDPConfig.JWKSPath of the IdP the
	// adapter was mounted with. Read only when IDProvider is true.
	//
	// A missing leading "/" is added, since the value is a path below the
	// prefix. A value that collides with a route this document already
	// describes is ignored rather than allowed to replace it, which would drop
	// a real endpoint from the spec; NewIDP refuses such a JWKSPath outright,
	// so the collision can only come from this field disagreeing with the IdP.
	JWKSPath string
	// OIDC adds the IdP's other four routes — the discovery document,
	// authorize, token and userinfo — to the document, at the fixed paths
	// OIDCDiscoveryPath, OIDCAuthorizePath, OIDCTokenPath and
	// OIDCUserInfoPath below APIPrefix. Set it when the adapter was mounted on
	// an Auth built WithIDP, and leave it false otherwise: those routes exist
	// only under that configuration, exactly as the JWKS route does.
	//
	// It is a second flag rather than more of IDProvider because the two
	// describe different things to different readers. IDProvider publishes a
	// key so that somebody else's resource server can verify this IdP's
	// tokens, and a deployment may well want that route documented and no
	// more — it is the only one a verifier calls. OIDC advertises an
	// authorization server a relying party is invited to drive. A deployment
	// that serves the OIDC endpoints on a mux of its own, through
	// (*IDP).RegisterHandlers under some other base path, leaves this false
	// and documents them where it mounted them.
	//
	// Unlike JWKSPath these paths are not configurable, so there is nothing to
	// tell this document; see the OIDC path constants.
	//
	// Setting ResourceServer alongside it drops OIDCAuthorizePath and
	// OIDCTokenPath again, because the adapters do not mount those two in that
	// mode; the discovery document and userinfo stay. See OIDCMount.
	OIDC bool

	// Docs mirrors HTTPConfig.Docs.Enabled: the two documentation routes the
	// adapters then mount, GET <prefix>/openapi.json and GET <prefix>/docs, are
	// described by the document too. Set the two together, or the spec omits
	// two routes the server serves — or documents two it does not.
	//
	// The reference's generator describes neither, under any option: it emits
	// the auth routes and stops (openapi.ts:68 onwards), so a document with
	// this flag carries two path items the reference's never has. That half of
	// the docs-routes-are-opt-in deviation is registered in compatibility.go.
	// A document that hid the endpoint it is served from would be a document
	// the conformance suite could not hold to the mount in both directions,
	// which is the check that keeps the rest of it honest.
	Docs bool
	// ResourceServer mirrors HTTPConfig.ResourceServer: the credential routes
	// the adapters then leave unmounted are left out of the spec too. Set the
	// two together, or the spec documents routes that answer 404.
	ResourceServer bool
	// UI mirrors HTTPConfig.UI.Enabled: it adds GET <prefix>/ui/config, the
	// document the built-in UI reads its branding and feature flags from. The
	// adapters mount that route only under that flag, so set the two together.
	UI bool
}

OpenAPIInfo holds metadata for the generated OpenAPI spec.

type Option

type Option func(*authBuilder) error

Option configures Auth initialization.

func WithBcryptCost added in v0.2.0

func WithBcryptCost(cost int) Option

WithBcryptCost sets the bcrypt cost used for every password hash the library writes. See Config.BcryptCost.

Unlike the zero value of Config.BcryptCost, which means "unset" and resolves to bcrypt.DefaultCost, calling this Option with 0 is an error: an explicit setter reached with an unset value is a caller mistake, not a request for the default. Omit the Option to get the default.

func WithEmailChangeSender added in v0.3.0

func WithEmailChangeSender(sender EmailChangeSender) Option

WithEmailChangeSender wires delivery for POST <prefix>/change-email/request. The message goes to the *new* address: it is a verification of the new mailbox. Without it the route mails nothing and still answers 200.

Pass a callback, or EmailChangeMailer.Send for the built-in verify-email template, which is what the reference mails here.

func WithEmailChangedSender added in v0.4.0

func WithEmailChangedSender(sender EmailChangedSender) Option

WithEmailChangedSender wires the notice POST <prefix>/change-email/confirm mails to the OLD address once the change is applied — the reference's config.email.sendEmailChanged (auth.router.ts:1060-1066). Without it the route mails nothing and still answers 200. A failing sender answers the generic 500 with the change already committed, as the reference does.

Pass a callback, or EmailChangedMailer.Send for the built-in email-changed template.

func WithEmailVerificationSender added in v0.3.0

func WithEmailVerificationSender(sender EmailVerificationSender) Option

WithEmailVerificationSender wires verification delivery for POST <prefix>/send-verification-email. Without it the route mails nothing and still answers 200.

Pass a callback, or EmailVerificationMailer.Send for the built-in verify-email template.

func WithIDP added in v0.6.0

func WithIDP(idp *IDP) Option

WithIDP registers an OIDC Identity Provider on the Auth, which is what makes every adapter mount its JWKS document at GET <prefix><IDPConfig.JWKSPath>, public and ahead of any middleware.

The reference registers that route from the same condition — an idProvider block with a key, or enabled (auth.router.ts:473-475) — where here the condition is having built an IDP and passed it here.

The same switch mounts the four OIDC endpoints beside it, at the paths (*IDP).RegisterHandlers uses and below the same prefix: OIDCDiscoveryPath, OIDCAuthorizePath, OIDCTokenPath and OIDCUserInfoPath, each for every method. Those four are this port's own — the reference has no OIDC authorization server — and see OIDCMounts for the list and RegisterHandlers for the host that would rather serve them on a mux of its own. Nothing about how /login, /refresh or the session tokens work changes either way.

An IDP built with a nil Service adopts the one this Auth is being built around, which is the only order a host can write: NewIDP wants a *Service and a *Service only exists once New has returned. So

idp, err := auth.NewIDP(auth.IDPConfig{Signer: key}, nil)
a, err := auth.New(auth.WithUserStore(store), auth.WithIDP(idp))

leaves idp fully wired.

An IDP that already carries a different Service — one constructed directly, or one a previous auth.New already bound — is refused, and New returns an error: its /token and /userinfo would answer from that other Service's user store while this Auth's routes answered from this one. Build a second IDP for a second Auth. One IDP per Auth is also what keeps the binding free of a data race, since nothing rebinds an IDP that may already be serving requests.

func WithIssuer

func WithIssuer(issuer string) Option

WithIssuer configures token issuer.

func WithLogger

func WithLogger(fn func(format string, args ...any)) Option

WithLogger provides optional library logging callback.

func WithMagicLinkSender added in v0.3.0

func WithMagicLinkSender(sender MagicLinkSender) Option

WithMagicLinkSender wires magic-link delivery. Without it POST <prefix>/magic-link/send answers 500 EMAIL_NOT_CONFIGURED, because the route cannot put the link in its response body.

Pass a callback to send the mail yourself, or MagicLinkMailer.Send to use the built-in templates over a MailerTransport.

func WithMetadataProvider

func WithMetadataProvider(store UserMetadataStore) Option

WithMetadataProvider wires optional metadata support.

func WithOAuth added in v0.2.0

func WithOAuth(wiring OAuthWiring) Option

WithOAuth wires the OAuth service and the linking stores.

A Provisioning policy is validated here, so a deployment whose configuration names an unknown OnEmailMatch mode or a FieldMap target that does not exist fails at construction rather than at the first login.

func WithPasswordResetSender added in v0.3.0

func WithPasswordResetSender(sender PasswordResetSender) Option

WithPasswordResetSender wires password-reset delivery for POST <prefix>/forgot-password. Without it the route still answers 200 {"success":true} and mails nothing, which is what a reference deployment with no email block does — see delivery_password_email.go.

Pass a callback to send the mail yourself, or PasswordResetMailer.Send to use the built-in password-reset template over a MailerTransport.

func WithPasswordVerifier added in v0.7.0

func WithPasswordVerifier(verify PasswordVerifier) Option

WithPasswordVerifier sets Config.PasswordVerifier, the migration seam: the hook POST <prefix>/login consults for a user whose stored hash did not verify the supplied password, so a deployment can accept the password its previous identity provider held, adopt it as a local hash and drop the dependency.

auth.WithPasswordVerifier(func(ctx context.Context, u auth.User, pw string) (bool, bool, error) {
	if u.Metadata["legacyIdP"] != "acme" {
		return false, false, nil
	}
	ok, err := acme.CheckPassword(ctx, u.Email, pw)
	return ok, ok, err
})

The marker check on the first line of that example is a requirement, not decoration: the hook is reached for every account whose stored hash does not verify, including OAuth-only and magic-link-only accounts that never had a password, and it is reached from an unauthenticated route. See PasswordVerifier, which also explains why adopting a password overwrites whatever hash was stored.

It is an additive port extension: the reference verifies bcrypt directly on its login path and has no such hook (local.strategy.ts:19-29). Omitting this Option is the reference's behaviour. Passing nil is refused, in the style of the sender Options: an explicit setter reached with nothing to set is a caller mistake, not a request to turn the seam off.

func WithRBACProvider

func WithRBACProvider(store RolesPermissionsStore) Option

WithRBACProvider wires optional role/permission support.

func WithRequire2FA

func WithRequire2FA(enabled bool) Option

WithRequire2FA enables mandatory second-factor login checks.

func WithSMSCodeSender added in v0.3.0

func WithSMSCodeSender(sender SMSCodeSender) Option

WithSMSCodeSender wires SMS delivery. Without it POST <prefix>/sms/send answers 500 SMS_NOT_CONFIGURED.

Pass a callback to send the message yourself, or SMSTransportSender over an SMSTransport such as HTTPSMSTransport.

func WithSecret

func WithSecret(secret string) Option

WithSecret configures the JWT signing secret.

func WithSessionCheckOn added in v0.2.0

func WithSessionCheckOn(mode string) Option

WithSessionCheckOn selects when a session's revocation state is re-checked: allcalls, refresh (the default) or none. Without allcalls the auth middleware cannot report a revoked session, so a client that relies on the SESSION_REVOKED signal has to opt in here.

func WithSessionStore

func WithSessionStore(store SessionStore) Option

WithSessionStore sets a custom SessionStore implementation.

func WithSettingsStore added in v0.6.0

func WithSettingsStore(store SettingsStore) Option

WithSettingsStore sets Config.Settings: the store holding the global runtime settings an administrator flips — the reference's routerOptions.settingsStore (auth.router.ts:92). Only AuthSettings.Require2FA is acted on, by POST <prefix>/2fa/disable; see SettingsStore for what the rest is for.

store := auth.NewMemorySettingsStore()
required := true
_, _ = store.UpdateSettings(ctx, auth.AuthSettings{Require2FA: &required})
auth.WithSettingsStore(store)

func WithSiteURLs added in v0.4.0

func WithSiteURLs(urls ...string) Option

WithSiteURLs sets Config.SiteURLs: the base URLs the front ends are served from. The first is canonical, every one of them is allowlisted for the per-request origin match — see Config.SiteURLs and Auth.ResolveSiteURL.

auth.WithSiteURLs("https://www.example.com", "https://app.example.com")

Calling it with no arguments leaves the list empty, which is the reference's "no siteUrl configured" and not an error.

func WithTemplateStore added in v0.4.0

func WithTemplateStore(store TemplateStore) Option

WithTemplateStore sets Config.Templates: the store whose mail templates override the built-ins for every ready-made mailer the service calls — the reference's config.templateStore (mailer.service.ts:146, 156-179). See TemplateStore and MailTemplater.RenderMail for the rendering rule.

store := auth.NewMemoryTemplateStore()
auth.WithTemplateStore(store)

func WithTenantProvider

func WithTenantProvider(store TenantStore) Option

WithTenantProvider wires optional tenant support.

func WithTokenClaimsBuilder

func WithTokenClaimsBuilder(fn TokenClaimsBuilder) Option

WithTokenClaimsBuilder customizes token claims creation. The hook may add claims and override the six base claims; the session claims (sid, tid, jti, typ, iss, iat, exp) are reserved and cannot be set from it — see Config.BuildTokenClaims.

TokenClaimsBuilder is an alias of the func type this always took, so a literal still satisfies it; StaticClaims, UserFieldClaims, ChainClaims and (*ClaimsWebhook).Build are ready-made ones (claims.go, claims_webhook.go).

func WithTokenTTLs

func WithTokenTTLs(access, refresh time.Duration) Option

WithTokenTTLs configures access and refresh TTL.

func WithTwoFactorAppName added in v0.4.0

func WithTwoFactorAppName(name string) Option

WithTwoFactorAppName sets Config.TwoFactorAppName: the issuer an authenticator app shows for a TOTP enrolment made through POST <prefix>/2fa/setup. It is the reference's `twoFactor.appName`.

auth.WithTwoFactorAppName("Example App")

Left unset, the URI carries Config.Issuer, as it always has — see Config.TwoFactorAppName. Passing "" is the same as not calling it.

func WithUserStore

func WithUserStore(store UserStore) Option

WithUserStore sets a custom UserStore implementation.

type PasswordResetDelivery added in v0.3.0

type PasswordResetDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	// Email is the stored address of the account the reset was requested for,
	// not the address as the request spelled it. An unknown address never
	// reaches a sender at all: the route is silent about who is registered.
	Email string `json:"email"`
	// Token is the plaintext token POST <prefix>/reset-password accepts.
	Token string `json:"token"`
	// ExpiresAt is the stored expiry, Config.ResetTokenTTL from now (1 hour, as
	// in the reference). Verification allows Config.ClockSkew past it, so a
	// message quoting this value understates the window rather than overstating
	// it.
	ExpiresAt time.Time `json:"expiresAt"`
	// LinkBase and Lang are what MagicLinkDelivery's are: the base the adapter
	// resolved for this request (auth.router.ts:785-786) and the request's
	// emailLang (:779, :788). LinkBase wins over PasswordResetMailer.BaseURL when
	// set; a Lang of "it" or "en" wins over its Locale.
	LinkBase string `json:"linkBase,omitempty"`
	Lang     string `json:"lang,omitempty"`
}

PasswordResetDelivery is what a PasswordResetSender is handed.

Like MagicLinkDelivery it carries the plaintext token — the store holds only its hash — plus the address the mail goes to and the ids a sender needs to read anything else back with. No User, no password hash: a sender is host code that may log what it receives.

type PasswordResetMailer added in v0.3.0

type PasswordResetMailer struct{ TokenMailer }

PasswordResetMailer is the ready-made PasswordResetSender: it renders the password-reset template (TemplatePasswordReset — the built-in, or a TemplateStore's override) and hands the result to a MailerTransport, as the reference's sendPasswordReset does (mailer.service.ts:185-188).

Use it as a sender through its Send method:

mailer, err := auth.NewGatewayMailerTransport(auth.MailerConfig{
	Endpoint: endpoint, APIKey: apiKey, From: "noreply@example.com",
})
// handle err
auth.WithPasswordResetSender(auth.NewPasswordResetMailer(
	mailer,
	"Example App",
	"https://app.example.com/auth",
).Send)

func NewPasswordResetMailer added in v0.3.0

func NewPasswordResetMailer(transport MailerTransport, appName, baseURL string) *PasswordResetMailer

NewPasswordResetMailer builds a PasswordResetMailer with the built-in templates. appName is what a template mentioning {{.appName}} shows; the built-ins mention none.

func (*PasswordResetMailer) Send added in v0.3.0

Send renders and delivers the reset mail. Its signature is PasswordResetSender's.

type PasswordResetSender added in v0.3.0

type PasswordResetSender func(ctx context.Context, delivery PasswordResetDelivery) error

PasswordResetSender delivers a password-reset token.

An error does NOT fail POST <prefix>/forgot-password: that route answers 200 {"success":true} whatever happens. See Auth.ForgotPassword.

type PasswordVerifier added in v0.7.0

type PasswordVerifier func(ctx context.Context, user User, password string) (ok bool, migrated bool, err error)

PasswordVerifier is the seam itself: a host-supplied check consulted for a user whose stored hash did not verify the password a login request carried.

It is handed the user record as the store holds it, so that a migration marker the host wrote - a metadata key, a column of its own, a magic placeholder in the hash - is what it keys on, and the plaintext password. A verifier is host code that may log or forward what it receives, so it receives what it needs to answer and nothing else.

Key on that marker first, and answer (false, false, nil) before doing anything else when it is absent. That is a requirement of writing a verifier, not a stylistic flourish, and it follows from which logins reach the hook: every login whose stored hash did not verify does.

  • That is not only the imported row. An account that never had a password - OAuth-only, magic-link-only - carries an empty PasswordHash, so its failed logins reach the hook too, carrying whatever plaintext the request held. The reference refuses those before it compares anything (local.strategy.ts:23-25); this port hands them to host code instead, and the marker is what keeps host code from doing anything with them.
  • The hook sits on an unauthenticated route. A verifier that calls the old provider for any address it is handed turns POST <prefix>/login into an amplifier aimed at that provider - and at its lockout counters - for every address an attacker cares to name. A marker read out of the user record already in hand costs nothing and cuts that off; whatever call survives the marker must still be bounded and rate-limited by the verifier itself.

The results:

  • ok=false: not the password. The login answers ErrInvalidCredentials, the same value, and therefore the same 401 body, that a wrong local password produces.

  • ok=true, migrated=true: the password, and the service should adopt it. The login hashes it at Config.BcryptCost and writes it through UserPasswordStore.UpdatePassword - the path ResetPassword and ChangePassword already write through - before it issues anything. From then on the local hash verifies and the hook is never reached for this user again, which is how the migration ends. A marker the host keeps in its own data is the host's to clear, and the verifier is the natural place to clear it from.

    Adoption overwrites whatever hash was stored, not only an empty one. The hook is reached for any hash that failed to verify, so a verifier that is not gated on a migration marker can answer ok for a live account and replace its working local password with whatever password the request carried. This is the sharpest edge on the seam and the marker gate is what blunts it.

    The adopted password is deliberately not measured against Config.MinPasswordLen. It is an inherited credential rather than one being chosen here, and refusing it would strand the account on the old system for good; Register, ResetPassword and ChangePassword, where a password is actually chosen, all still enforce the length. The one value refused is the empty string: if a verifier answers ok for an empty password the login proceeds, but nothing is written, because a bcrypt hash of "" would make empty-password login succeed forever and would permanently close the passwordless initial-password path that keys on an empty PasswordHash (wire_password_email.go:347). An empty password behaves as migrated=false.

  • ok=true, migrated=false: the password, but keep nothing. The login proceeds and the stored hash is left alone, so the next login consults the verifier again. This is for a host that means to keep owning the credential rather than hand this library a copy of it.

A non-nil err is a failure to decide, not a rejection: the old system was unreachable, the call timed out, the answer did not parse. The login fails closed on it and ok is ignored.

The call is made on the request goroutine, inside the login, so a verifier that talks to the network must honour ctx and bound its own time.

type PendingLinkStore

type PendingLinkStore interface {
	Save(ctx context.Context, state string, meta OAuthPendingMeta, ttl time.Duration) error
	Get(ctx context.Context, state string) (OAuthPendingMeta, error)
	Delete(ctx context.Context, state string) error
}

PendingLinkStore holds temporary OAuth link state.

type PublicLinkedAccount added in v0.2.0

type PublicLinkedAccount struct {
	Provider          string    `json:"provider"`
	ProviderAccountID string    `json:"providerAccountId"`
	Email             string    `json:"email,omitempty"`
	Name              string    `json:"name,omitempty"`
	Picture           string    `json:"picture,omitempty"`
	LinkedAt          time.Time `json:"linkedAt"`
}

PublicLinkedAccount is the wire projection of a linked account. The field names and their optionality are the reference's LinkedAccount interface (linked-accounts-store.interface.ts:4-17), which is what the Angular client, the served auth.js and the family admin UI read.

Email is populated by every link the library writes, because the reference's linkAccount calls always pass `email: user.email` from the callback profile (auth.router.ts:1336-1343, :1578-1583). Name and Picture are optional in the reference's interface and its own linkAccount calls never set them, so they only ever appear when a host-supplied LinkedAccountStore fills them in; the admin UI's `a.name || a.email || a.providerAccountId` therefore falls through to the email, as it does against the reference.

type PublicSession added in v0.2.0

type PublicSession struct {
	SessionHandle string    `json:"sessionHandle"`
	UserID        string    `json:"userId"`
	TenantID      string    `json:"tenantId,omitempty"`
	CreatedAt     time.Time `json:"createdAt"`
	ExpiresAt     time.Time `json:"expiresAt"`
}

PublicSession is the response-safe projection of Session, and the only session shape an adapter may serialise: Session carries the refresh-token hash, which is credential material and must never reach a response body.

The field names and the key set are the reference's SessionInfo (session.model.ts:8-27, wire-contract §3.9): sessionHandle, userId, createdAt, expiresAt and an optional tenantId. Both browser clients read sessionHandle off each entry and post it straight back to DELETE /sessions/{handle}.

There is deliberately no revokedAt key. SessionInfo has no such field, so no shipped client knows it exists or could filter on it, and the reference never needs one: revokeSession deletes the record, so a revoked session is absent from the next list rather than flagged in it. Emitting the tombstone as an extra key would be an unrequested addition to the contract and, worse, the only way a client could tell a live session from a dead one — see Auth.ListSessions, which drops them instead.

func NewPublicSession added in v0.2.0

func NewPublicSession(session Session) PublicSession

NewPublicSession projects a Session onto its response-safe representation.

func NewPublicSessions added in v0.2.0

func NewPublicSessions(sessions []Session) []PublicSession

NewPublicSessions projects a session list. The result is never nil: a store that returns no sessions must serialise as [] and not as null, because a client iterating response.sessions breaks on null.

type PublicTenant

type PublicTenant struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	IsActive  bool      `json:"isActive"`
	CreatedAt time.Time `json:"createdAt"`
}

PublicTenant is the response-safe projection of Tenant. The tenant Config blob is server-side configuration and is never serialised.

type PublicUser

type PublicUser struct {
	// Sub is the identifier under the name the reference uses and the shipped
	// clients require. Both cast it non-nullably — awesome-node-auth-flutter
	// does `json['sub'] as String` (auth_user.dart:76) and ng-awesome-node-auth
	// declares `sub: string` (auth.service.ts:10) — so omitting it does not
	// degrade a client, it throws inside one. It duplicates ID rather than
	// replacing it because `id` shipped in 0.2.0 and both clients read that as
	// optional, so emitting both breaks nobody.
	Sub   string `json:"sub"`
	ID    string `json:"id"`
	Email string `json:"email"`
	// Role is the reference's single-role field, distinct from Roles. Omitted
	// when empty, which it always is until something writes it.
	Role string `json:"role,omitempty"`
	// LoginProvider is always emitted, LoginProviderLocal when nothing recorded
	// one: the reference's /me body is its token payload, which carries
	// `loginProvider: user.loginProvider ?? 'local'` unconditionally
	// (auth.router.ts:379, :666-668).
	LoginProvider   string         `json:"loginProvider"`
	TenantID        string         `json:"tenantId,omitempty"`
	FirstName       string         `json:"firstName,omitempty"`
	LastName        string         `json:"lastName,omitempty"`
	PhoneNumber     string         `json:"phoneNumber,omitempty"`
	IsEmailVerified bool           `json:"isEmailVerified"`
	IsTOTPEnabled   bool           `json:"isTotpEnabled"`
	Roles           []string       `json:"roles,omitempty"`
	Permissions     []string       `json:"permissions,omitempty"`
	Tenants         []PublicTenant `json:"tenants,omitempty"`
	Metadata        map[string]any `json:"metadata,omitempty"`
	CustomClaims    map[string]any `json:"customClaims,omitempty"`
	CreatedAt       time.Time      `json:"createdAt"`
}

PublicUser is the response-safe projection of User. It is the only user shape adapters may serialise: credential material (password hash, TOTP secret, one-time token hashes and the pending email change) is deliberately absent.

func NewPublicUser

func NewPublicUser(user User) PublicUser

NewPublicUser projects a User onto its response-safe representation.

type RegisterInput

type RegisterInput struct {
	Email    string
	Password string
	TenantID string
}

RegisterInput is the payload to create a user and initial session.

type ResetPasswordInput

type ResetPasswordInput struct {
	Token       string
	NewPassword string
}

type ResourceServerConfig added in v0.6.0

type ResourceServerConfig struct {
	// JWKSURL is the full URL of the issuer JWKS endpoint, for example
	// https://auth.example.com/auth/.well-known/jwks.json. It is required:
	// NewJWKSClient panics on an empty one, and on one that is not an
	// absolute http or https URL, rather than building a client that refuses
	// every bearer token for the lifetime of the process. The reference refuses
	// the same misconfiguration the same way, by throwing when the mode is
	// wired without its settings (jwks-auth.middleware.ts:38-40).
	JWKSURL string
	// Issuer is the expected iss claim. Empty means the claim is not checked,
	// which is the reference behaviour for an absent issuer
	// (token.service.ts:137).
	Issuer string
	// CacheTTL is how long a fetched document stays fresh. Zero means
	// DefaultJWKSCacheTTL.
	CacheTTL time.Duration
	// FetchTimeout bounds one fetch. Zero means DefaultJWKSFetchTimeout.
	FetchTimeout time.Duration
	// MinRefreshInterval is the shortest interval between two refetches forced
	// by an unknown kid. Zero means DefaultJWKSMinRefreshInterval; a negative
	// value turns the limit off and restores the reference behaviour, which
	// refetches on every unknown kid.
	MinRefreshInterval time.Duration
	// Client is the HTTP client the fetch goes through. Nil means a client of
	// this package, which carries no cookie jar and no redirect policy of its
	// own. A host that needs a proxy, a pinned CA pool or a tracing transport
	// supplies one here.
	Client *http.Client
}

ResourceServerConfig is how a deployment points itself at the JWKS of the instance that issues its tokens. It is the reference ResourceServerConfig (auth-config.model.ts:112-140) minus the enabled flag: in Go the flag is the act of constructing the middleware, and unmounting the credential routes is HTTPConfig.ResourceServer.

type RolesPermissionsStore

type RolesPermissionsStore interface {
	AddRoleToUser(ctx context.Context, userID, role string, tenantID string) error
	RemoveRoleFromUser(ctx context.Context, userID, role string, tenantID string) error
	GetRolesForUser(ctx context.Context, userID, tenantID string) ([]string, error)
	CreateRole(ctx context.Context, role string, permissions []string) error
	DeleteRole(ctx context.Context, role string) error
	AddPermissionToRole(ctx context.Context, role, permission string) error
	RemovePermissionFromRole(ctx context.Context, role, permission string) error
	GetPermissionsForRole(ctx context.Context, role string) ([]string, error)
	GetPermissionsForUser(ctx context.Context, userID, tenantID string) ([]string, error)
	UserHasPermission(ctx context.Context, userID, permission, tenantID string) (bool, error)
}

RolesPermissionsStore manages RBAC assignments and permissions.

type SMSCodeDelivery added in v0.3.0

type SMSCodeDelivery struct {
	UserID   string `json:"userId"`
	TenantID string `json:"tenantId"`
	// Phone is the recipient's stored number. The send route refuses a user
	// without one before it gets this far (PHONE_NOT_SET), so it is never empty.
	Phone string `json:"phone"`
	// Code is the plaintext one-time code; the store holds only its hash.
	Code      string    `json:"code"`
	ExpiresAt time.Time `json:"expiresAt"`
}

SMSCodeDelivery is what an SMSCodeSender is handed. See MagicLinkDelivery for why it carries so little.

type SMSCodeSendInput

type SMSCodeSendInput struct {
	UserID   string
	Email    string
	TenantID string
}

type SMSCodeSender added in v0.3.0

type SMSCodeSender func(ctx context.Context, delivery SMSCodeDelivery) error

SMSCodeSender delivers an SMS one-time code. See MagicLinkSender for the failure contract.

func SMSTransportSender added in v0.3.0

func SMSTransportSender(transport SMSTransport) SMSCodeSender

SMSTransportSender adapts an SMSTransport into an SMSCodeSender, formatting the code with SMSCodeMessage. It is the SMS counterpart of MagicLinkMailer.

type SMSCodeVerifyInput

type SMSCodeVerifyInput struct {
	UserID   string
	Email    string
	TenantID string
	Code     string
}

type SMSStore

type SMSStore interface {
	UpdateSMSCode(ctx context.Context, userID, tenantID, codeHash string, expiry time.Time) error
	GetUserBySMSCodeHash(ctx context.Context, userID, tenantID, codeHash string) (User, error)
	ClearSMSCode(ctx context.Context, userID, tenantID string) error
}

SMSStore persists sms code state.

type SMSTransport added in v0.3.0

type SMSTransport interface {
	Send(ctx context.Context, phone, message string) error
}

SMSTransport delivers a text message to a handset. It is the SMS counterpart of MailerTransport: SMSTransportSender turns one into the SMSCodeSender that Config.SendSMSCode wants.

type Service

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

Service is the main entry point for authentication operations.

func NewService

func NewService(cfg Config, users UserStore, sessions SessionStore, opts ...ServiceOption) (*Service, error)

NewService builds an auth service with validated configuration.

func (*Service) AddUserToTenant

func (s *Service) AddUserToTenant(ctx context.Context, userID, tenantID string) error

func (*Service) AssignRole

func (s *Service) AssignRole(ctx context.Context, userID, role, tenantID string) error

func (*Service) Authenticate added in v0.5.0

func (s *Service) Authenticate(ctx context.Context, accessToken string) (User, error)

Authenticate verifies an access token and returns the user it names. The token's signature, type, issuer and lifetime are checked, the session is validated when Config.SessionCheckOn is "allcalls", and the user is loaded from the UserStore; the failure sentinels are Me's — ErrInvalidToken, ErrSessionNotFound, ErrSessionRevoked. It is what the adapters' Middleware calls for every request to a protected route.

The user comes back with the optional stores' enrichment — metadata, roles, permissions, tenants — because host handlers behind the middleware read those off UserFromContext for their own authorisation decisions, and each is a local store read that never fails the request: a store error is logged and the field left empty, as it always was. What Authenticate does not do is run Config.BuildTokenClaims, so CustomClaims is nil. The builder is a mint-time hook — with a ClaimsWebhook behind it, a network round trip — and what it computed is already inside the token the request carried; running it again on every request would make each protected route pay for a claim set nothing reads. Me is the call that runs it, for the one route whose body is the profile. This is also where the reference draws the line: its authMiddleware verifies the token and hands the route the verified payload as req.user (auth.middleware.ts:44-61). It does consult the session store — the allcalls check at :47-53 and the last-active touch at :56-58 — but never the user store and never buildTokenPayload; only its /me calls buildPayload again. Authenticate reads the user store in addition, because the Go middleware hands the route a User rather than a payload.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, in ChangePasswordInput) error

func (*Service) CleanupExpiredSessions

func (s *Service) CleanupExpiredSessions(ctx context.Context) (int, error)

func (*Service) ConfirmEmailChange

func (s *Service) ConfirmEmailChange(ctx context.Context, in ConfirmEmailChangeInput) error

ConfirmEmailChange spends an email-change token: the pending address becomes the account's, the token is cleared, and then the OLD address is sent the email-changed notice through Config.SendEmailChanged — the reference's order (auth.router.ts:1056-1066: oldEmail and newEmail read off the user, updateEmail, updateEmailChangeToken(null…), then sendEmailChanged(oldEmail, newEmail)).

A failing notice is returned as an error, joined to ErrDeliveryFailed, with the change already applied. That is the reference's behaviour, reproduced rather than softened: its sendEmailChanged call is inside the route's try block, so a throwing mailer reaches handleError and the route answers the generic 500 for a change that has been committed (:1068-1069). Nothing is rolled back, in either port — the token is spent and the address has moved.

func (*Service) CreateRole

func (s *Service) CreateRole(ctx context.Context, role string, permissions []string) error

func (*Service) CreateTenant

func (s *Service) CreateTenant(ctx context.Context, name string, config map[string]any) (Tenant, error)

func (*Service) DeleteAccount

func (s *Service) DeleteAccount(ctx context.Context, in DeleteAccountInput) error

DeleteAccount runs the reference's account cleanup in the reference's order (auth.router.ts:1597-1636): sessions, then role assignments, then tenant memberships, then metadata, and the user record last. The order is not cosmetic — deleting the identity first and failing anywhere afterwards leaves sessions that still authenticate a user who no longer exists.

func (*Service) DisableTOTP

func (s *Service) DisableTOTP(ctx context.Context, userID, tenantID string) error

func (*Service) FindUser added in v0.2.0

func (s *Service) FindUser(ctx context.Context, userID, email, tenantID string) (User, error)

FindUser resolves a user by id, or by email when no id is given. The passwordless routes need it because they have to tell "no such user" apart from "that user has no phone number" — two different statuses in the contract — and the send calls answer both with silence.

func (*Service) ForgotPassword

func (s *Service) ForgotPassword(ctx context.Context, in ForgotPasswordInput) (string, error)

func (*Service) GetMetadata

func (s *Service) GetMetadata(ctx context.Context, userID string) (map[string]any, error)

func (*Service) IssueTempToken added in v0.2.0

func (s *Service) IssueTempToken(ctx context.Context, user User) (string, error)

IssueTempToken mints the step-up token a 2FA challenge hands the client. Its lifetime is Config.TempTokenTTL, 5 minutes by default, matching the reference's hardcoded '5m'.

func (*Service) IssueTokensForUser added in v0.2.0

func (s *Service) IssueTokensForUser(ctx context.Context, userID, tenantID string) (User, AuthTokens, error)

IssueTokensForUser starts a session for an identity that has already been proven by something other than a password — here, an emailed account-link token. It is the one service method this route group needed and the core did not have: every other issuance path (Register, Login, VerifyMagicLink, VerifySMSCode, VerifyTOTP) reaches newSessionTokens through a credential check that does not apply.

func (*Service) ListSessions

func (s *Service) ListSessions(ctx context.Context, userID, tenantID string) ([]Session, error)

func (*Service) Login

func (s *Service) Login(ctx context.Context, in LoginInput) (User, AuthTokens, error)

Login verifies a password and issues a session, reporting a 2FA-gated account as ErrTwoFactorRequired.

It is the narrow form of LoginWithChallenge, which is what the HTTP adapters call: the sentinel can say that a second factor is needed and nothing more, and the step-up routes need the token that comes with the challenge. Kept as it was for direct callers. See login_2fa.go.

It shares the password path with LoginWithChallenge but stops at the decision rather than at the built challenge, so the sentinel is reported whenever a second factor is *required* and not merely whenever one could be minted. Going through the challenge would make this wrapper fail wherever minting fails — Config.BuildTokenClaims returning an error is enough (token.go:66-71) — and answer a 2FA account with that error instead of ErrTwoFactorRequired. Nothing is minted here: the token this call cannot return is never built.

func (*Service) LoginWithChallenge added in v0.3.0

func (s *Service) LoginWithChallenge(ctx context.Context, in LoginInput) (LoginResult, error)

LoginWithChallenge is the full POST /login path: it verifies the password and then either issues a session or mints the step-up challenge.

Service.Login is the narrower form of the same call, kept for direct callers: it collapses a challenge back into ErrTwoFactorRequired, which says a second factor is needed and cannot say anything more.

A challenge that cannot be minted is returned as the error, not as a challenge with an empty TempToken and not as ErrTwoFactorRequired. This call's whole point is the token: a client handed a challenge without one would believe it had a step-up credential and be refused by every §3 route, and the sentinel would spell the 403 2FA_REQUIRED body this PR removed from /login — a wire answer no longer served on this route, telling the client to step up with a token it was never given. So the failure surfaces: HTTPErrorFor maps it to HTTPErrInternal (wire.go:172-173), which is what a broken host hook is.

func (*Service) Logout

func (s *Service) Logout(ctx context.Context, refreshToken string) error

func (*Service) LogoutAccessToken added in v0.2.0

func (s *Service) LogoutAccessToken(ctx context.Context, accessToken string) error

LogoutAccessToken revokes the session named by an access token's sid claim.

func (*Service) Me

func (s *Service) Me(ctx context.Context, accessToken string) (User, error)

Me is Authenticate plus the custom claims: the profile GET /me renders, with CustomClaims filled from Config.BuildTokenClaims so that the body reflects the hook the way the reference's /me does — its body is buildPayload(user) (auth.router.ts:656-680). A builder failure is logged and leaves CustomClaims nil rather than failing the call, as it always has: /me is a read, and a read should not go dark because a mint-time hook is down.

func (*Service) Refresh

func (s *Service) Refresh(ctx context.Context, refreshToken string) (AuthTokens, error)

func (*Service) Register

func (s *Service) Register(ctx context.Context, in RegisterInput) (User, AuthTokens, error)

Register creates a user and opens a session for them.

The two fields are required, and their absence is refused before any store is touched — the order the default register handler on the private dev line node-auth works in, which throws AuthError('Email and password are required', 'INVALID_INPUT', 400) before it hashes the password or calls userStore.create (node-auth auth.router.ts:515-525, resolved against DevLineRevision). That is the dev line and not the published reference at ReferenceRevision, whose /register is mounted only when the host supplies options.onRegister (auth.router.ts:713) and which has no default handler and no INVALID_INPUT code at all. Without the check an empty password reached the length test and came back as ErrWeakPassword, which tells a caller who sent no password to choose a stronger one, and an empty address reached the store and created a user with a blank email.

The check runs after normalisation, so an address that is only whitespace is refused here rather than stored as the empty string. The dev line tests the untrimmed body value and would accept it; this is the narrower reading, and it can only refuse a request the dev line would have turned into an account with no usable address.

A successful call also opens a session, which the published reference does not: see the register-issues-a-session entry in CompatibilityNotes.

func (*Service) RequestEmailChange

func (s *Service) RequestEmailChange(ctx context.Context, in ChangeEmailRequestInput) (string, error)

func (*Service) ResetPassword

func (s *Service) ResetPassword(ctx context.Context, in ResetPasswordInput) error

func (*Service) RevokeSessionByID

func (s *Service) RevokeSessionByID(ctx context.Context, sessionID string) error

func (*Service) RevokeUserSession added in v0.2.0

func (s *Service) RevokeUserSession(ctx context.Context, userID, tenantID, sessionID string) error

RevokeUserSession revokes one of a user's own sessions. Ownership is part of the lookup rather than a check bolted on afterwards: a handle belonging to another user is indistinguishable from one that never existed.

A handle that resolves to a session which is already revoked, or which has expired, is also indistinguishable from one that never existed. The reference resolves the handle with getSession, documented to return null "when the session does not exist or has expired" (session-store.interface.ts:51-54), and its revokeSession deletes the record — so both cases reach the same `if (!session || …) → 404` as an unknown handle (auth.router.ts:762-767). A second DELETE of the same handle is therefore *not* idempotently successful, and neither is a DELETE of an expired one.

func (s *Service) SendMagicLink(ctx context.Context, in MagicLinkSendInput) (string, error)

SendMagicLink mints a magic link, stores its hash and delivers it through Config.SendMagicLink.

The returned plaintext token is redundant for an HTTP caller — the routes answer {"success":true} and drop it — and is kept for a direct caller that delivers the link itself, and for tests.

func (*Service) SendSMSCode

func (s *Service) SendSMSCode(ctx context.Context, in SMSCodeSendInput) (string, error)

SendSMSCode mints a one-time code, stores its hash and delivers it through Config.SendSMSCode. See SendMagicLink on the returned plaintext.

The HTTP routes refuse an unconfigured deployment before they reach this call (Auth.SMSConfigured), because the reference checks config.sms at the top of the route rather than in the strategy. This check is what protects a direct caller, and it is why a handler that skipped the precheck still cannot store a code nobody can deliver.

func (*Service) SendVerificationEmailToken

func (s *Service) SendVerificationEmailToken(ctx context.Context, in EmailVerificationInput) (string, error)

func (*Service) SetupTOTP

func (s *Service) SetupTOTP(ctx context.Context, userID, tenantID string) (string, error)

func (*Service) StartTOTPEnrolment added in v0.2.0

func (s *Service) StartTOTPEnrolment(ctx context.Context, userID, tenantID string) (TOTPSetup, error)

StartTOTPEnrolment mints an enrolment secret and its provisioning URI.

func (*Service) TokenTTLs added in v0.2.0

func (s *Service) TokenTTLs() (access, refresh time.Duration)

TokenTTLs reports the configured access and refresh token lifetimes. The HTTP layer needs them to give the auth cookies the same lifetime as the tokens they carry.

func (*Service) UpdateMetadata

func (s *Service) UpdateMetadata(ctx context.Context, userID string, metadata map[string]any) error

func (*Service) UpdatePhoneNumber added in v0.2.0

func (s *Service) UpdatePhoneNumber(ctx context.Context, in AddPhoneInput) (User, error)

UpdatePhoneNumber sets or clears a user's phone number.

func (*Service) UpdateProfile

func (s *Service) UpdateProfile(ctx context.Context, in UpdateProfileInput) (User, error)

UpdateProfile applies a partial profile update: a nil field in the input is one the caller did not submit and must be left as it is stored (§3.5).

UserAccountStore.UpdateProfile takes both names by value, so a nil field is resolved against the current record before the write rather than by widening the store interface — a store written against this package keeps compiling and still gets partial semantics. The read-modify-write is not atomic; two concurrent partial patches of *different* fields can still lose one. Making it atomic needs a store-level patch primitive, which is a breaking interface change and its own item; silently erasing a field on every single partial call is the failure this closes.

func (*Service) UserHasPermission

func (s *Service) UserHasPermission(ctx context.Context, userID, permission, tenantID string) (bool, error)

func (*Service) VerifyEmail

func (s *Service) VerifyEmail(ctx context.Context, in VerifyEmailInput) error
func (s *Service) VerifyMagicLink(ctx context.Context, in MagicLinkVerifyInput) (User, AuthTokens, error)

func (*Service) VerifyMagicLinkForUser added in v0.2.0

func (s *Service) VerifyMagicLinkForUser(ctx context.Context, in MagicLinkVerifyInput, userID string) (User, AuthTokens, error)

VerifyMagicLinkForUser consumes a magic link that must belong to userID, the step-up case: the caller has already identified the user through a step-up token and the link is only the second factor.

It exists because the identity check has to happen before a session is created. Service.VerifyMagicLink issues one as part of verifying, so a caller that compared the ids afterwards would refuse the request on the wire and still leave a live session for the link's owner — a session that shows up in GET /sessions and that only the owner can revoke. A mismatch here returns ErrMagicLinkOwnerMismatch and issues nothing.

The link is consumed even on a mismatch, matching the reference, which burns it in the strategy before the router compares ids (magic-link.strategy.ts:50 versus auth.router.ts:1150-1152).

func (*Service) VerifySMSCode

func (s *Service) VerifySMSCode(ctx context.Context, in SMSCodeVerifyInput) (User, AuthTokens, error)

func (*Service) VerifyTOTP

func (s *Service) VerifyTOTP(ctx context.Context, userID, tenantID, code string) (User, AuthTokens, error)

func (*Service) VerifyTOTPSetup

func (s *Service) VerifyTOTPSetup(ctx context.Context, userID, tenantID, secret, code string) error

func (*Service) VerifyTempToken added in v0.2.0

func (s *Service) VerifyTempToken(token string) (TempTokenSubject, error)

VerifyTempToken resolves the identity a step-up token names, or ErrInvalidToken.

type ServiceOption

type ServiceOption func(*Service)

func WithMetadataStore

func WithMetadataStore(store UserMetadataStore) ServiceOption

func WithRolesPermissionsStore

func WithRolesPermissionsStore(store RolesPermissionsStore) ServiceOption

func WithTenantStore

func WithTenantStore(store TenantStore) ServiceOption

type Session

type Session struct {
	ID               string
	UserID           string
	TenantID         string
	RefreshTokenHash string
	CreatedAt        time.Time
	ExpiresAt        time.Time
	RevokedAt        *time.Time
}

Session stores refresh-token bound session metadata.

type SessionAdminStore

type SessionAdminStore interface {
	ListSessionsForUser(ctx context.Context, userID, tenantID string) ([]Session, error)
	RevokeSessionByID(ctx context.Context, sessionID string) error
	DeleteExpiredSessions(ctx context.Context, now time.Time) (int, error)
}

SessionAdminStore exposes session listing and cleanup primitives.

type SessionLookupStore

type SessionLookupStore interface {
	GetSessionByID(ctx context.Context, sessionID string) (Session, error)
}

SessionLookupStore resolves sessions by session ID.

type SessionStore

type SessionStore interface {
	CreateSession(ctx context.Context, session Session) (Session, error)
	GetSessionByRefreshTokenHash(ctx context.Context, tokenHash string) (Session, error)
	UpdateSession(ctx context.Context, session Session) error
}

SessionStore persists refresh-token sessions.

type SettingsStore added in v0.6.0

type SettingsStore interface {
	GetSettings(ctx context.Context) (AuthSettings, error)
	UpdateSettings(ctx context.Context, patch AuthSettings) (AuthSettings, error)
}

SettingsStore keeps the global runtime settings (settings-store.interface.ts:28-40). It is optional: without one nothing changes, exactly as in the reference, where the settings check on /2fa/disable is skipped altogether when no store is configured (auth.router.ts:890).

GetSettings returns the zero AuthSettings when nothing has been configured — the reference empty object (:31-32) — and reserves the error for the store itself failing. A failing GetSettings fails the route it was called from closed, with the reference generic 500: see Auth.TwoFactorPolicy.

UpdateSettings applies patch as a shallow merge over what is stored: a field the patch does not carry is left untouched (:36-38), a field it does carry replaces the stored one outright. MergeSettings is that rule, and every implementation has to apply it. The reference method returns void and leaves the caller to read the result back; this one returns what is stored afterwards, as TemplateStore does.

A store is wired once with WithSettingsStore (Config.Settings).

type SseDistributor

type SseDistributor interface {
	Publish(ctx context.Context, channel string, msg SseMessage) error
	Subscribe(ctx context.Context, channel string) (<-chan SseMessage, error)
}

SseDistributor broadcasts SSE messages to all connected clients. Implement this interface for Redis/Kafka-based multi-instance scaling.

type SseHub

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

SseHub manages in-process SSE subscriptions.

func NewSseHub

func NewSseHub() *SseHub

NewSseHub creates an in-process SSE hub.

func (*SseHub) Publish

func (h *SseHub) Publish(_ context.Context, channel string, msg SseMessage) error

Publish sends a message to all subscribers of channel.

func (*SseHub) Subscribe

func (h *SseHub) Subscribe(_ context.Context, channel string) (<-chan SseMessage, error)

Subscribe registers a new client channel for the given user/channel key.

func (*SseHub) Unsubscribe

func (h *SseHub) Unsubscribe(channel string, ch <-chan SseMessage)

Unsubscribe removes a client channel from the hub.

type SseMessage

type SseMessage struct {
	ID    string
	Event string
	Data  map[string]any
}

SseMessage is a single SSE payload.

type TOTPSetup added in v0.2.0

type TOTPSetup struct {
	Secret     string `json:"secret"`
	OTPAuthURL string `json:"otpauthUrl"`
}

TOTPSetup is the enrolment material POST /2fa/setup hands back. Enrolment is stateless in the reference and stateless here: nothing is persisted until the client returns the secret to /2fa/verify-setup together with a code that verifies against it.

The reference also returns a `qrCode` PNG data URL. It is absent here: the root package is stdlib plus golang.org/x/crypto and a QR encoder is neither. A client renders OTPAuthURL itself, which is what the Rust port of this family does too.

type TOTPStore

type TOTPStore interface {
	UpdateTOTPSecret(ctx context.Context, userID, tenantID, secret string, enabled bool) error
}

TOTPStore persists authenticator setup.

type TelemetryEvent

type TelemetryEvent struct {
	ID        string
	EventName string
	UserID    string
	TenantID  string
	IP        string
	UserAgent string
	Success   bool
	Error     string
	Timestamp time.Time
	Meta      map[string]any
}

TelemetryEvent represents a single persisted auth event for analytics.

type TelemetryFilter

type TelemetryFilter struct {
	UserID    string
	TenantID  string
	EventName string
	Since     time.Time
	Until     time.Time
	Limit     int
}

TelemetryFilter restricts query results.

type TelemetryStore

type TelemetryStore interface {
	Record(ctx context.Context, event TelemetryEvent) error
	Query(ctx context.Context, filter TelemetryFilter) ([]TelemetryEvent, error)
}

TelemetryStore persists auth telemetry events.

type TempTokenSubject added in v0.2.0

type TempTokenSubject struct {
	UserID   string
	TenantID string
}

TempTokenSubject is the identity a step-up token names. It is deliberately not a User: the routes that accept a tempToken re-read the user from the store, as the reference does, so a flag set since the token was minted is honoured.

type TemplateStore added in v0.4.0

type TemplateStore interface {
	GetMailTemplate(ctx context.Context, id string) (MailTemplate, bool, error)
	ListMailTemplates(ctx context.Context) ([]MailTemplate, error)
	UpdateMailTemplate(ctx context.Context, id string, patch MailTemplatePatch) (MailTemplate, error)
	GetUITranslations(ctx context.Context, page string) (UITranslation, bool, error)
	ListUITranslations(ctx context.Context) ([]UITranslation, error)
	UpdateUITranslations(ctx context.Context, page string, translations map[string]map[string]string) (UITranslation, error)
}

TemplateStore keeps mail templates and UI translations (template-store.interface.ts:13-43). It is optional: without one the built-in templates render, exactly as the reference without a templateStore.

The Get methods answer (value, false, nil) for an id or page the store does not hold — the reference's null — and reserve the error for the store itself failing. Update is an upsert in both halves: a missing id is created from an empty template (memory-template.store.ts:16-21) and a page is created on its first translations. Both Update methods return what is stored afterwards, which the reference's void methods leave the caller to read back.

A store is wired once with WithTemplateStore (Config.Templates) and reaches every ready-made mailer the service calls; a MailTemplater used outside a service call reads its own Store field. The UI half — GetUITranslations and its siblings — is kept for the UI router, which is what reads it in the reference (ui.router.ts); nothing in this package serves it yet.

type Tenant

type Tenant struct {
	ID        string
	Name      string
	IsActive  bool
	Config    map[string]any
	CreatedAt time.Time
}

Tenant represents an isolated workspace or organization.

type TenantStore

type TenantStore interface {
	CreateTenant(ctx context.Context, tenant Tenant) (Tenant, error)
	GetTenantByID(ctx context.Context, id string) (Tenant, error)
	GetAllTenants(ctx context.Context) ([]Tenant, error)
	UpdateTenant(ctx context.Context, id string, update Tenant) error
	DeleteTenant(ctx context.Context, id string) error
	AssociateUserWithTenant(ctx context.Context, userID, tenantID string) error
	DisassociateUserFromTenant(ctx context.Context, userID, tenantID string) error
	GetTenantsForUser(ctx context.Context, userID string) ([]Tenant, error)
	GetUsersForTenant(ctx context.Context, tenantID string) ([]string, error)
}

TenantStore manages tenants and user memberships.

type TokenClaimsBuilder added in v0.5.0

type TokenClaimsBuilder = func(ctx context.Context, user User) (map[string]any, error)

TokenClaimsBuilder is the type of Config.BuildTokenClaims and of the hook WithTokenClaimsBuilder accepts: given the user a token is being minted for, it returns the claims to spread over the six base claims. It is an alias, not a distinct type, so a plain func literal satisfies both exactly as it did before the name existed.

The result may override sub, email, role, loginProvider, isEmailVerified and isTotpEnabled; the session claims sid, tid, jti, typ, iss, iat and exp are reserved — written after the merge — and cannot be set from any builder (see issueToken). A builder error fails the mint: the login, refresh or step-up that needed the token answers 500 rather than issuing a token carrying fewer claims than the deployment configured.

func ChainClaims added in v0.5.0

func ChainClaims(builders ...TokenClaimsBuilder) TokenClaimsBuilder

ChainClaims returns a builder that runs builders in order and merges their results, later ones winning on a shared claim name — so a StaticClaims placed after a UserFieldClaims overrides it, and a hand-written hook placed last has the final say. The first error stops the chain and is returned as it is; the builders after it do not run. Nil builders are skipped, and a chain of none adds nothing.

func StaticClaims added in v0.5.0

func StaticClaims(claims map[string]any) TokenClaimsBuilder

StaticClaims returns a builder that adds the same claims to every token: the {const: <value>} form of the consumer's extraClaims table.

The map is copied on construction and again on every call, so neither a caller that goes on mutating the map it passed in nor one that edits a returned result can change what the next token carries. A nil or empty map is a builder that adds nothing.

StaticClaims has no error to return, so it cannot refuse a reserved session claim name the way UserFieldClaims does: an entry named sid, tid, jti, typ, iss, iat or exp is discarded by issueToken on every mint, exactly as the same name from a hand-written hook is. A deployment that decodes the {const: ...} table should check the names before building.

func UserFieldClaims added in v0.5.0

func UserFieldClaims(fields map[string]string) (TokenClaimsBuilder, error)

UserFieldClaims returns a builder that copies fields of the User into named claims: the {fromUserField: <field>} form of the consumer's extraClaims table. fields maps each claim name to the User field it reads, spelled as the field is on GET /me:

id, email, role, tenantId, firstName, lastName, phoneNumber,
isEmailVerified, isTotpEnabled, loginProvider

A field name outside that list, or an empty claim name, is an error from this constructor and never from a mint: a mapping is configuration, and a typo in configuration should fail at startup rather than turn every login into a 500. So is a claim name that is one of the reserved session claims — sid, tid, jti, typ, iss, iat, exp: issueToken writes those after the merge, so such a mapping would never reach a token, and a mapping that can never reach a token is a configuration error rather than a claim. The six base claims (sub, email, role, loginProvider, isEmailVerified, isTotpEnabled) are not reserved and may be mapped over, as the reference allows. loginProvider reads as LoginProviderLocal when the user has none recorded, as the base claim and /me do.

Every mapped claim is emitted on every token with whatever the field holds: an empty firstName becomes an empty-string claim, not an absent one. A mapping declares that a claim exists; making its presence depend on the value would leave a consumer unable to tell "not configured" from "empty".

type TokenMailer added in v0.3.0

type TokenMailer struct {
	// Transport delivers the rendered message. Required.
	Transport MailerTransport
	// BaseURL is what the URL builders above build the link under when the
	// delivery carries no LinkBase of its own — the static fallback, as
	// MagicLinkMailer.BaseURL is. A delivery's LinkBase wins over it.
	BaseURL string
	// Locale selects the built-in template set: "en" (the default) or "it". An
	// unknown locale falls back to English, as MailTemplater.RenderMail does.
	//
	// It is the default, as MagicLinkMailer.Locale is: a delivery whose Lang is
	// "it" or "en" — the request's emailLang body field — renders in that
	// language instead, and any other Lang defers to this (resolveLang).
	Locale string
	// Templates renders subject and bodies. The constructors fill this in; set
	// Templates.Store to render from a TemplateStore outside a service call
	// (inside one, Config.Templates is found on its own).
	Templates *MailTemplater
}

TokenMailer is the shared body of the three ready-made senders below and the port's equivalent of the reference's config.email.mailer path, where a deployment configures a transport instead of writing a callback. It is MagicLinkMailer's shape, factored out because three copies of it would be three places for a template name or a Content-Type to drift.

type TwoFactorChallenge added in v0.3.0

type TwoFactorChallenge struct {
	// TempToken is the step-up credential the §3 routes accept. It is typed
	// tokenTypeTemp, so unlike the reference's it is not also an access token —
	// see the tokenTypeTemp comment for why that deliberate difference is kept.
	TempToken string
	// Methods is available2faMethods: the factors this user can use, in the
	// reference's order. It is empty only when SetupRequired is set.
	Methods []string
	// SetupRequired selects the second of the reference's two branches: a policy
	// demands a second factor and the account has no method at all, so the client
	// must enrol one before it can be challenged.
	SetupRequired bool
}

TwoFactorChallenge is what POST /login answers a user who has a second factor: the step-up token, and the factors they can actually present it with.

It carries what goes on the wire and nothing else, for the reason MagicLinkDelivery carries so little (delivery.go): the token is a credential, and a value that also held the User would hand a password hash and a TOTP secret to anything that logs a challenge.

type UIBranding added in v0.7.0

type UIBranding struct {
	PrimaryColor   string
	SecondaryColor string
	LogoURL        string
	SiteName       string
	BgColor        string
	BgImage        string
	CardBg         string
}

UIBranding is the static branding block, under the reference's field names (auth-config.model.ts:353-380). Every member is optional, and the empty string means unset: the reference's chain of `||` treats an empty string and an absent key alike, so a value configured empty falls through to the next candidate exactly as a missing one does.

CustomLogo and LogoURL are both here because the reference has both, and because it prefers the first: the served logoUrl is the stored settings.ui.logoUrl, then config.ui.customLogo, then config.ui.logoUrl (ui.router.ts:128). customLogo is the documented field (auth-config.model.ts:354-355) and logoUrl is kept there as a legacy one (:357-361).

type UIConfig added in v0.7.0

type UIConfig struct {
	APIPrefix    string            `json:"apiPrefix"`
	Features     UIFeatures        `json:"features"`
	UI           UIConfigBranding  `json:"ui"`
	Translations map[string]string `json:"translations"`
	Lang         string            `json:"lang"`
	// Headless echoes UIOptions.Headless. It is the one member the route adds
	// rather than getUiConfig (ui.router.ts:166-168), which is why it is last
	// and why the error fallback carries it too.
	Headless bool `json:"headless"`
}

UIConfig is the body of GET <prefix>/ui/config, with the reference's keys in the reference's order (ui.router.ts:136-142 plus :168).

apiPrefix is where the API routes are mounted, which is what lets the UI be served from one path and call the API at another. The reference derives it from the mounted router's baseUrl with a trailing /ui removed (:98); here it is HTTPConfig.Prefix, which is the same value by construction.

translations is the flat key -> value map for lang, never null: a deployment with no template store, a store holding no config page, or a page holding neither the requested language nor English all yield the empty object (:105-112).

type UIConfigBranding added in v0.7.0

type UIConfigBranding struct {
	PrimaryColor   string `json:"primaryColor"`
	SecondaryColor string `json:"secondaryColor"`
	LogoURL        string `json:"logoUrl,omitempty"`
	SiteName       string `json:"siteName"`
	CustomCSS      string `json:"customCss,omitempty"`
	BgColor        string `json:"bgColor,omitempty"`
	BgImage        string `json:"bgImage,omitempty"`
	CardBg         string `json:"cardBg,omitempty"`
}

UIConfigBranding is the served ui object (ui.router.ts:125-134): the static branding with the stored settings applied on top.

The five optional members are omitted rather than sent null, which is what JSON.stringify does with the undefined the reference leaves them as. The three that are always present have defaults behind them and can never be empty.

customCss is here and customLogo is not: the logo is resolved into logoUrl, and the CSS is passed through from the static configuration.

type UIFeatures added in v0.7.0

type UIFeatures struct {
	Register       bool `json:"register"`
	MagicLink      bool `json:"magicLink"`
	SMS            bool `json:"sms"`
	Google         bool `json:"google"`
	GitHub         bool `json:"github"`
	ForgotPassword bool `json:"forgotPassword"`
	VerifyEmail    bool `json:"verifyEmail"`
	TwoFactor      bool `json:"twoFactor"`
	// contains filtered or unexported fields
}

UIFeatures is the served features object (ui.router.ts:114-123): which login affordances the UI should offer. Each flag says that this deployment can actually perform the thing, which is why they are derived from the wiring rather than configured.

  • register is always true. The reference mounts POST /register only when routerOptions.onRegister is supplied and reports that same condition here (auth.router.ts:712-715, ui.router.ts:115); this port always mounts the route, so the honest answer about the routes it serves is always true. Registered as the deviation register-route-is-always-mounted.
  • magicLink, sms, forgotPassword and verifyEmail follow the delivery seam: Config.SendMagicLink, Config.SendSMSCode, Config.SendPasswordReset and Config.SendEmailVerification. The reference reads config.email.sendX or a configured mailer for the three email ones and config.sms for the other (:116-121); this port has no mailer block on Config, so the sender is the whole condition.
  • verifyEmail additionally requires the deployment to want verification at all: Config.EmailVerificationMode other than none, as normalised by the service. The reference asks emailVerificationMode !== 'none' || requireEmailVerification (:121), and an unset mode passes that test while behaving as none everywhere else in its own code (auth-config.model.ts:286-289). This port has no requireEmailVerification and normalises an unset mode to none throughout, so it answers false where the reference answers true for a deployment that configured a verification sender and left the mode alone. Registered as the deviation ui-config-verify-email-follows-the-effective-mode, which is where the reasoning lives.
  • google and github report a configured OAuth provider of that name (:118-119, WithOAuth here).
  • twoFactor is Config.TwoFactorAppName, this port's spelling of the reference's twoFactor block, which holds nothing but appName (auth-config.model.ts:272-274, ui.router.ts:122). Config.Require2FA is not read: it makes the factor mandatory, not available, and the reference has no equivalent of it.

func (UIFeatures) MarshalJSON added in v0.7.0

func (f UIFeatures) MarshalJSON() ([]byte, error)

MarshalJSON writes the eight flags, or the three the error fallback writes.

The fallback object really is shorter (ui.router.ts:147): a client that keyed on features.magicLink gets undefined from a deployment whose settings store is down, rather than false. That is a bug in the reference — it drops five flags on an error path the client cannot distinguish from success — and it is reproduced rather than fixed (reference-issues N32), because the family's clients are written against the document as it is and a port that filled the five in would be answering something no node-auth server ever answers.

The flag is unexported and set in one place, so the type stays a plain collection of booleans for anyone building or reading one. Round-tripping a collapsed value through an encoder and a decoder yields an uncollapsed one.

type UIOptions added in v0.7.0

type UIOptions struct {
	// Enabled mounts the UI. The reference gates its whole ui router on
	// config.ui.enabled (auth.router.ts:1639-1648); this port gates
	// GET <prefix>/ui/config on the same flag, so with it false the route is not
	// registered and the router answers whatever it answers for an unknown path.
	//
	// It is also what points an emailed link at a UI page rather than at the API
	// route itself — see HTTPConfig.UILink. The deprecated HTTPConfig.UIEnabled
	// is an alias for it.
	Enabled bool
	// Headless is the reference's config.ui.headless (auth-config.model.ts:343):
	// the SPA case, in which the hosting application provides its own pages and
	// the router serves only this document and the static assets. This port
	// serves no pages yet, so the flag decides nothing here and is echoed in the
	// document, which is where auth.js reads it from to stop redirecting on an
	// expired session (:334-336, ui.router.ts:168).
	Headless bool
	// Branding is the static half of the served ui object: the deployment's
	// colours, logo and site name, each of which a settings store may override
	// per request. See UIBranding.
	Branding UIBranding
	// CustomCSS is the reference's config.ui.customCss
	// (auth-config.model.ts:348-353), served as the ui object's customCss. It is
	// the one member of that object a settings store cannot override, because
	// the reference reads it from the static config alone (ui.router.ts:130).
	CustomCSS string
	// DefaultLang is the language the document falls back to when the request
	// carries no lang query parameter. The reference reads that fallback from
	// config.email.mailer.defaultLang (ui.router.ts:103); this port has no
	// mailer block on Config — MailerConfig belongs to the transport a host
	// builds — so the UI's own default lives here. Empty means UIDefaultLang.
	DefaultLang string
	// Assets and Uploads are the two directories the reference's ui router
	// serves: the built-in pages and scripts (ui.router.ts:72-93, :177-180) and
	// the logos and backgrounds an administrator uploaded (:184-190). Nothing
	// reads them yet — this change mounts the config route and nothing else —
	// and they are declared now so that a host configures the UI in one place
	// once the pages land.
	Assets  fs.FS
	Uploads fs.FS
}

UIOptions is the reference's config.ui block (auth-config.model.ts:321-381): everything the built-in UI is configured with. It reaches the route as HTTPConfig.UI.

type UISettings added in v0.6.0

type UISettings struct {
	PrimaryColor   *string `json:"primaryColor,omitempty"`
	SecondaryColor *string `json:"secondaryColor,omitempty"`
	LogoURL        *string `json:"logoUrl,omitempty"`
	SiteName       *string `json:"siteName,omitempty"`
	LogoPath       *string `json:"logoPath,omitempty"`
	BgColor        *string `json:"bgColor,omitempty"`
	BgImage        *string `json:"bgImage,omitempty"`
	CardBg         *string `json:"cardBg,omitempty"`
}

UISettings is the reference's AuthSettings.ui (settings-store.interface.ts:79-91): the branding the admin UI customization panel writes, which the UI config route will serve once it lands.

The fields are pointers so that a patch can carry one of them without blanking the other seven, and omitempty so that a key nobody ever set is absent rather than null — the reference stores whatever object the admin router last merged (admin.router.ts:979-981) and JSON.stringify emits only the keys it holds. The reference types every field as required inside the optional ui object; in practice its own PATCH route writes partial objects, so required is the type rather than the data.

type UITranslation added in v0.4.0

type UITranslation struct {
	Page         string                       `json:"page"`
	Translations map[string]map[string]string `json:"translations"`
}

UITranslation is the stored translation set of one UI page (template-store.interface.ts:8-11): lang -> key -> value.

type UpdateProfileInput

type UpdateProfileInput struct {
	UserID    string
	TenantID  string
	FirstName *string
	LastName  *string
}

UpdateProfileInput is the payload behind PATCH /profile. FirstName and LastName are pointers because the route is a *partial* update: the reference types the body { firstName?: string | null, lastName?: string | null } and passes it through verbatim, so a key the caller omitted arrives at the store as undefined and leaves the stored column alone (wire-contract §3.5, user-store.interface.ts:136).

nil therefore means "not submitted, leave as it is" and a pointer to "" means "clear it" — the distinction a plain string cannot carry, and without which sending only firstName silently erases the stored lastName.

type User

type User struct {
	ID           string
	Email        string
	PasswordHash string
	TenantID     string
	PhoneNumber  string
	FirstName    string
	LastName     string
	Role         string
	// LoginProvider names the identity provider that created the account: an
	// OAuth provider's name for a user OAuthService.HandleCallback created,
	// empty for one registered with a password. Empty is read as
	// LoginProviderLocal wherever it reaches the wire — the reference's
	// `user.loginProvider ?? 'local'` (auth.router.ts:379) — so a store that
	// never persisted the field needs no migration to keep answering "local".
	LoginProvider                string
	IsEmailVerified              bool
	Require2FA                   bool
	IsTOTPEnabled                bool
	TOTPSecret                   string
	ResetTokenHash               string
	ResetTokenExpiresAt          *time.Time
	MagicLinkTokenHash           string
	MagicLinkTokenExpiresAt      *time.Time
	SMSCodeHash                  string
	SMSCodeExpiresAt             *time.Time
	EmailVerificationTokenHash   string
	EmailVerificationTokenExpiry *time.Time
	PendingEmail                 string
	EmailChangeTokenHash         string
	EmailChangeTokenExpiry       *time.Time
	Metadata                     map[string]any
	Roles                        []string
	Permissions                  []string
	Tenants                      []Tenant
	CustomClaims                 map[string]any
	CreatedAt                    time.Time
	UpdatedAt                    time.Time
}

User is the canonical identity model.

func UserFromContext added in v0.6.0

func UserFromContext(ctx context.Context) (User, bool)

UserFromContext reads the authenticated principal a middleware stored.

func UserFromRS256Claims added in v0.6.0

func UserFromRS256Claims(claims map[string]any) User

UserFromRS256Claims builds the principal a resource server hands its handlers from verified RS256 claims, without reading any store.

This is the whole point of resource-server mode: the instance has no user database, so the token is the user record. The six named fields are the reference base payload (auth.router.ts:378-384) — sub, email, role, loginProvider, isEmailVerified, isTotpEnabled — plus tid, which this port writes as the tenant (token.go issueToken) and which a tenant-scoped handler cannot do without. Everything in the token, those included, is also left in CustomClaims, because the reference assigns the entire payload (jwks-auth.middleware.ts:69) and a scope or permission claim an issuer adds has to reach the handler somehow.

Fields the token cannot carry are zero: no PasswordHash, no Roles, no Permissions, no Tenants, no CreatedAt. A handler that needs them has to read its own store.

The name says RS256 because that is the path it was written for, but the mapping is the claim set, not the algorithm: the middleware's cookie path builds its principal with this same function, from the claims of a locally verified HS256 token. Both are the same six base claims plus tid, because issueToken writes exactly them (token.go).

type UserAccountStore

type UserAccountStore interface {
	UpdateProfile(ctx context.Context, userID, tenantID, firstName, lastName string) (User, error)
	DeleteUser(ctx context.Context, userID, tenantID string) error
}

UserAccountStore adds account lifecycle operations.

type UserMetadataStore

type UserMetadataStore interface {
	GetMetadata(ctx context.Context, userID string) (map[string]any, error)
	UpdateMetadata(ctx context.Context, userID string, metadata map[string]any) error
	ClearMetadata(ctx context.Context, userID string) error
}

UserMetadataStore manages arbitrary key/value user metadata.

type UserPasswordStore

type UserPasswordStore interface {
	UpdatePassword(ctx context.Context, userID, tenantID, passwordHash string) error
	UpdateResetToken(ctx context.Context, userID, tenantID, tokenHash string, expiry time.Time) error
	GetUserByResetTokenHash(ctx context.Context, tokenHash string) (User, error)
	ClearResetToken(ctx context.Context, userID, tenantID string) error
}

UserPasswordStore adds password lifecycle methods.

type UserPhoneStore added in v0.2.0

type UserPhoneStore interface {
	UpdatePhoneNumber(ctx context.Context, userID, tenantID, phoneNumber string) (User, error)
}

UserPhoneStore adds the phone-number update behind POST /add-phone. It is a capability of its own rather than a method on UserAccountStore so that a store written against the existing interface keeps compiling; a store that does not implement it makes the route answer 501, which is the reference's own behaviour when userStore.updatePhoneNumber is absent (auth.router.ts:700-703).

type UserStore

type UserStore interface {
	CreateUser(ctx context.Context, user User) (User, error)
	GetUserByEmail(ctx context.Context, email, tenantID string) (User, error)
	GetUserByID(ctx context.Context, id, tenantID string) (User, error)
}

UserStore persists and resolves users.

type VerifyEmailInput

type VerifyEmailInput struct {
	Token string
}

type WebhookDispatcher

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

WebhookDispatcher delivers signed webhook payloads.

func NewWebhookDispatcher

func NewWebhookDispatcher(endpoints ...WebhookEndpoint) *WebhookDispatcher

NewWebhookDispatcher creates a dispatcher for the given endpoints.

func (*WebhookDispatcher) Dispatch

func (d *WebhookDispatcher) Dispatch(ctx context.Context, event Event)

Dispatch delivers the event to all matching endpoints.

type WebhookEndpoint

type WebhookEndpoint struct {
	ID     string
	URL    string
	Secret string
	Events []string // event names to forward; empty = all
}

WebhookEndpoint configures a single outbound webhook target.

type WebhookPayload

type WebhookPayload struct {
	ID        string         `json:"id"`
	Event     string         `json:"event"`
	Timestamp time.Time      `json:"timestamp"`
	Data      map[string]any `json:"data"`
}

WebhookPayload is the envelope sent to webhook targets.

Directories

Path Synopsis
adapter
chi
gin
internal/wiretest
Package wiretest is the shared wire-contract conformance suite for the HTTP adapters.
Package wiretest is the shared wire-contract conformance suite for the HTTP adapters.
examples
chi-postgres command
Chi + PostgreSQL example for awesome-go-auth.
Chi + PostgreSQL example for awesome-go-auth.
echo-sqlite command
Echo + SQLite example for awesome-go-auth.
Echo + SQLite example for awesome-go-auth.
gin-mongodb command
Gin + MongoDB example for awesome-go-auth.
Gin + MongoDB example for awesome-go-auth.

Jump to

Keyboard shortcuts

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