Skip to main content
Version: Next (unreleased)

Local identities

The Node adapter can authenticate a small set of identities from private JSON files. This is for operator-provisioned accounts on a single SAG process. It is not registration, a self-service directory, or a portable identity-store backend. The boundary and its reasons are in ADR 0023, with plaintext TOTP storage defined by ADR 0025.

Before enabling it​

Local authentication requires:

  • Node.js 24.7.0 or newer with crypto.argon2;
  • a persistent directory with exactly one SAG process writing it;
  • an explicit, high-entropy SUBJECT_SALT which will never rotate;
  • the running instance's SAG_SECRET only when provisioning sealed upstream refresh credentials; and
  • a STATE_STORE_BACKEND, because password and second-factor attempt limits fail closed when there is no atomic counter.

memory is an adequate state store for a single-process installation. It resets its counters on restart and does not make a second process safe to add. Use a rate limit in the reverse proxy as another layer; the application limits are not a replacement for one.

The Node adapter deliberately ignores X-Forwarded-For for its network counter and uses the immediate socket address. Behind a reverse proxy, every request from that proxy therefore shares one bucket. Set LOCAL_AUTH_NETWORK_MAX_ATTEMPTS=0 to prevent one caller from exhausting that shared bucket, and enforce a real client-address limit at the trusted proxy itself. Keep the application limit enabled when clients connect directly.

Enable the backend and name its public routing scope:

LOCAL_IDENTITIES_BACKEND=file
LOCAL_IDENTITIES_DIR=/var/lib/sag/local-identities
LOCAL_IDENTITY_DOMAINS=example.com
SUBJECT_SALT=<at-least-32-random-bytes>
STATE_STORE_BACKEND=memory

LOCAL_IDENTITY_DOMAINS is public policy, not a list inferred from the files. An exact name serves that domain. *.example.com serves it and its subdomains; * serves every domain and should be set only deliberately. Every address in scope gets the same password page, whether its record exists, is disabled, or is malformed. Eligible upstreams remain available as alternatives on that page.

For record lookup, canonical means trimmed and case-folded; a plus tag is retained. jamie+one@example.com and jamie+two@example.com therefore name different local records even if a relying party's SANITISE_PLUS_EMAILS policy presents both without the tag. Their stable local subjects remain different because their record ids differ.

The complete limits and defaults are in Configuration.

Provision a record​

Use tools/generate-local-identity.js; do not construct password hashes or sealed refresh credentials by hand. Run it with the exact SUBJECT_SALT and identity directory used by the instance. Passwords, TOTP seeds, backup codes, and upstream links without refresh tokens can be provisioned without SAG_SECRET. The password is accepted only on standard input, never as an argument:

printf '%s\n' "$LOCAL_IDENTITY_PASSWORD" |
npm run generate-local-identity -- \
--email jamie.taylor@example.com \
--directory /var/lib/sag/local-identities \
--password-stdin \
--totp \
--backup-codes 10

LOCAL_IDENTITIES_DIR can supply the directory instead. --totp is optional; --backup-codes accepts zero to twenty. The password must contain 12 to 1,024 UTF-8 bytes. Run npm run generate-local-identity -- --help for the complete interface.

The tool prints newly generated TOTP and backup-code material once; capture it through the intended secure handover channel. Its output is secret and must not enter a build log. Passwords and backup codes are stored only as Argon2id verifiers. TOTP seeds are deliberately plaintext Base32 in the private JSON file so a management utility does not need the gateway's sealing key. Never put real credentials in shell history, logs, or a source repository.

Optional profile claims come from a JSON object passed with --claims. Only the fixed OpenID Connect profile allow-list is accepted, for example:

{
"name": "Jamie Taylor",
"preferred_username": "jamie.taylor"
}

The running instance applies its own PROFILE_CLAIMS and PROFILE_PICTURE filter again, so storing a permitted claim does not force it into a token.

The generator refuses to overwrite an existing address and has no replace mode. To change credentials, stop the SAG writer, preserve the existing stable id and filename, increment revision, and increment security_version in the replacement record. The security-version change is what invalidates old sessions and unredeemed codes. A replacement with a new random id also gives the person a new sub at every relying party.

Generated refresh-token ciphertexts are bound to the random id created with them, so do not copy one onto a record whose id differs. A password verifier is not identity-bound and can be generated in a separate empty directory, then copied into a stopped writer's existing record. Write the finished JSON through a mode 0600 temporary file and atomic rename; do not edit a live record in place.

Restart is not needed for a newly created file: the backend reads the record on authentication.

What the file contains​

The filename is not an ordinary hash. It is the full lower-case HMAC-SHA-256 of the canonical email address under a key derived from SUBJECT_SALT, followed by .json. This prevents a leaked directory listing being tested against a list of likely addresses. The email itself is not in the document.

The versioned JSON record contains:

  • a stable, random identity id, revision, and security version;
  • an optional disabled marker;
  • a durable MFA-required marker when any second factor was provisioned;
  • one PHC-format Argon2id password verifier;
  • bounded OpenID Connect profile claims;
  • up to five TOTP credentials, whose seeds are plaintext Base32 values;
  • up to twenty single-use backup codes, each retained only as an Argon2id verifier; and
  • up to twenty explicit upstream links, each matching a configured upstream id, verified issuer, and exact upstream subject. A retained refresh token is sealed and bound to that link.

Version 1 records require Argon2id at 64 MiB, three passes, and one lane. A different cost profile is rejected so present and missing identities perform equivalent password work. SAG validates the PHC parameters before allocating work, caps parallel operations with LOCAL_ARGON2_CONCURRENCY, and performs a real dummy verification for an unknown or unusable record. Files over 64 KiB, symlinks, non-regular files, invalid JSON, and fields outside the bounded schema are refused.

The adapter creates the directory as mode 0700 and writes replacement files as mode 0600. On POSIX it refuses existing directories and records with any group or other permission bits; on Windows the operator must apply equivalent access control. It flushes a temporary file and atomically renames it, with a revision comparison under an in-process lock. That comparison is not a distributed lock: never point two SAG processes or a separate live editor at the same writable directory.

TOTP and backup codes​

Each TOTP entry has a stable credential id and a secret containing the Base32 seed, plus optional label, algorithm (SHA1, SHA256, or SHA512), digits (6 or 8), and period (15 to 120 seconds). Defaults are SHA1, six digits, and thirty seconds. SAG validates the seed, normalises it to uppercase unpadded Base32, and maintains last_used_step for replay prevention. Seeds must decode to at least 80 bits; the generator uses 160 bits. Each credential must have a distinct seed, including after Base32 normalisation, so duplicate entries cannot bypass per-credential replay state.

Reading a record now reveals its authenticator seed. Restrict the directory, encrypt backups, and treat read access as access to that second factor. Changing SAG_SECRET neither changes nor invalidates a TOTP seed. An external manager can generate and replace these credentials without SAG_SECRET, but still needs SUBJECT_SALT to locate records by email and must respect the single-writer and security-version rules above.

The earlier, unreleased draft stored sealed TOTP values. Those ciphertexts are no longer accepted as secret: convert them offline using the old deployment's sealing key, or enrol a replacement seed. Preserve the replay marker when converting the same seed. A replacement seed must reset its replay marker and increment security_version. Do not distribute the old sealing key to the new management application.

A record with any TOTP credential or backup code requires a second factor after its password. TOTP accepts the configured number of time steps either side of the current one and records the accepted step so the same value cannot be replayed. A backup code is selected by its public short id, verified with Argon2id, and removed through a conditional write before authentication completes.

Give generated backup codes to the account holder once and keep no plaintext copy unless the recovery policy deliberately calls for an operator-held copy. Using the last backup code does not enrol a replacement factor. The durable MFA policy remains set, so an identity with no remaining factor is locked rather than downgraded to password-only. This feature has no self-service factor-management or password-reset flow; the operator must prepare an offline replacement record.

An upstream link is explicit. Put an array in a JSON file and pass it with --upstreams. One link has this shape:

[
{
"id": "work",
"upstream": "microsoft/examplecom",
"issuer": "https://login.microsoftonline.com/tenant-id/v2.0",
"subject": "opaque-upstream-subject",
"refresh_token_env": "WORK_REFRESH_TOKEN"
}
]

upstream is <provider>/<slug> from the corresponding UPSTREAM_<PROVIDER>_<SLUG>_* configuration. id names this link within the record. refresh_token_env is optional: it names an environment variable whose value the tool seals. A plaintext refresh_token in the JSON is rejected so a long-lived credential does not enter the source file.

Supply all of:

  • the upstream id shown by SAG's configuration;
  • the upstream token's verified iss;
  • the exact upstream sub; and
  • the same canonical email address as the local record.

At callback time SAG requires all four to match. Same-email accounts are never linked implicitly. On a match, the upstream sign-in uses the stable local identity id for sub; if the exchange supplies a refresh token, SAG seals it into that link, whether or not one was provisioned initially. The token is stored for that upstream relationship only and is never returned to the browser or relying party. SAG does not issue its own refresh tokens. The retained credential is not consumed by a background refresh flow in this release; storing it does not extend a SAG session or token.

What a relying party sees​

When the relying party asks for the email scope, relevant claims from password-only authentication include:

{
"acr": "urn:sag:acr:local-password",
"amr": ["pwd"],
"email": "jamie.taylor@example.com"
}

TOTP adds otp and mfa to amr; a backup code adds recovery and mfa. Both use urn:sag:acr:local-mfa. Most importantly, local-only authentication omits email_verified. Knowing the credentials for a record labelled with an address is not proof of control of its mailbox.

An exact linked-upstream authentication can emit email_verified: true, because that flow independently verified the address. Both routes use the same public or pairwise sub, derived from the record's stable random id. The upstream's sub is link evidence and never becomes SAG's subject.

Local authentication contexts do not silently satisfy federated contexts. A relying party which asks for urn:sag:acr:federated still requires an upstream sign-in even when the local record has TOTP.

To accept either local or federated MFA, request urn:sag:acr:mfa. Local password plus TOTP or a backup code satisfies that requirement and still returns urn:sag:acr:local-mfa, without email_verified. A password alone cannot satisfy it.

The password page supplies the selected username and current-password autocomplete hints. The MFA page gives authenticator codes a numeric keypad and one-time-code autocomplete; backup codes have a separate text field under Use a backup code, so their letters and hyphens remain enterable. Both forms work without JavaScript. With script enabled, a complete authenticator code is submitted after a 500 ms pause. The longest configured TOTP length controls auto-submit: an eight-digit or mixed-length identity cannot auto-submit a six-digit prefix. A six-digit code for a mixed-length identity remains manually submittable.

Disable, replace, back up, and restore​

SAG checks authoritative local state when it reuses a session and when an authorisation code is redeemed. Setting disabled: true, deleting the record, or incrementing security_version invalidates its sealed sessions and unredeemed codes. Tokens already issued keep their ordinary short lifetime. Changing only revision coordinates a file update and does not revoke credentials.

Back up the private directory and SUBJECT_SALT; the salt is required to find a record and reproduce its sub. If any upstream refresh credentials are retained, also preserve SAG_SECRET and any still-active SAG_SECRET_PREVIOUS needed to open them. Encrypt and restrict backups because they contain plaintext TOTP seeds.

Changing an address means creating the HMAC-named file for the new canonical address with the same stable identity id, then removing the old file while SAG is stopped. It does not verify the new mailbox. There is intentionally no live rename workflow, automatic merge, or reuse of a deleted identity id.

Master-secret rotation needs extra care when retained upstream refresh credentials outlive a session. Keep the old secret in SAG_SECRET_PREVIOUS until their offline rekey has completed. TOTP-only identities need no rekey:

SAG_SECRET="$NEW_SAG_SECRET" \
SAG_SECRET_PREVIOUS="$OLD_SAG_SECRET" \
SUBJECT_SALT="$UNCHANGED_SUBJECT_SALT" \
npm run generate-local-identity -- \
--rekey \
--directory /var/lib/sag/local-identities

Stop or drain the one SAG writer first. The command conditionally replaces every refresh-token ciphertext still using the previous secret, leaves security_version unchanged, and is safe to run again. It refuses to continue past a malformed or undecryptable record; fix that error and rerun it before removing SAG_SECRET_PREVIOUS. The full order is in Operations.