Skip to content

@shutter-network/concorde/password-auth

The Password Auth component authenticates a person by a password they traded once for a bearer Token. It owns that scheme's two secrets, the scrypt digest of the password and the digest of the Token, and it turns Authorization: Bearer <token> into the User the request acts as. It is an Auth, so the Public server holds it beside every other scheme a deployment accepts and composes them into the one hook a protected route takes.

createPasswordAuth makes one, and PasswordAuthOptions is what it takes. PasswordAuth is what comes back, carrying a programmatic API that replaces a password, mints a Token and revokes every Token of one User. None of the three has a route anywhere. IssuedToken is what a login answers with.

The constructor registers four routes at /auth on the Public server: POST /auth/tokens is the login, PUT /auth/password is self-service rotation, and the two DELETEs drop the presented Token and every Token of the User. Reading back which User is authenticated is not here: it is scheme-independent, so it belongs to the Users component.

Construct Users first, whose record every outcome carries. Nothing else takes this: a component with a protected route reads the Public server's hook, so which schemes a deployment accepts is which Auths it constructs, and construction order inside extend only decides the order they are asked in.

The tables are on @shutter-network/concorde/password-auth/schema and nowhere else. Both point a foreign key at the users table, so a configuration listing that subpath without @shutter-network/concorde/users/schema generates a constraint onto a table it never creates.

Example

A Gateway a person logs into, and one route of the Operator's own behind the server's hook.

ts
import { createGateway } from "@shutter-network/concorde/gateway";
import { createPasswordAuth } from "@shutter-network/concorde/password-auth";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { createUsers } from "@shutter-network/concorde/users";

const gateway = createGateway({
  databaseUrl: process.env.DATABASE_URL ?? "",
  runtime: createPiRuntime({ image: "my-agent:1" }),
  // Not loopback: the agent reaches this server from a container of its own.
  agentListen: { host: "0.0.0.0", port: 8081 },
  publicListen: { host: "0.0.0.0", port: 8080 },
  extend: ({ db, agentServer, publicServer }) => {
    const users = createUsers({ db, agentServer, publicServer });
    return {
      users,
      passwordAuth: createPasswordAuth({ db, users, publicServer, tokenTtl: 86_400_000 }),
    };
  },
  handlers: () => ({}),
});

await gateway.start();

// One transaction, so a User nobody can log in as never reaches the table.
const { db, users, passwordAuth } = gateway.components;
const admitted = await db.tx(async (tx) => {
  const user = await users.create(tx);
  await passwordAuth.setPassword(tx, user.id, "correct horse battery staple");
  return user;
});
console.log(`admitted ${admitted.id}, and that id is what they log in with`);

// A route of the Operator's own, behind the schemes this deployment accepts.
gateway.components.publicServer.fastify.get(
  "/whoami",
  { preHandler: gateway.components.publicServer.requireUser },
  async (request) => ({ id: request.concordeUser.id }),
);

Type Aliases

IssuedToken

ts
type IssuedToken = {
  readonly expiresAt: string;
  readonly token: string;
  readonly user: UserRecord;
};

What a login answers with: the Token, when it expires, and the User it belongs to.

The User is embedded rather than referenced, so a client needs no second request to know who it is.

Properties

expiresAt
ts
readonly expiresAt: string;

When it stops working, ISO 8601, from the lifetime this component was built with.

token
ts
readonly token: string;

The Token, in the only response that will ever carry it.

user
ts
readonly user: UserRecord;

The User it belongs to, Attributes and all.


PasswordAuth

ts
type PasswordAuth = Auth & {
  issueToken: <TSchema>(tx: Handle<TSchema>, user: string) => Promise<IssuedToken>;
  revoke: <TSchema>(tx: Handle<TSchema>, user: string) => Promise<void>;
  setPassword: <TSchema>(tx: Handle<TSchema>, user: string, password: string) => Promise<void>;
  start: () => Promise<void>;
  stop: () => Promise<void>;
};

The Password Auth component as an Auth: one route group, one authenticate, and a programmatic API.

It keeps a scrypt digest of each User's password and one row per issued Token. A User who has no password has no row rather than an empty one. A Token's plaintext exists once, in the response that issued it, so nothing here answers with one afterwards.

authenticate reads Authorization: Bearer <token>. A request with no such header carries nothing of this scheme, and the server asks the next Auth. A Token that is unknown or expired is refused, and so is a wrong password at the login route: those two and an id nobody holds are one answer, so nothing here reports who exists.

Every write in the programmatic API takes the caller's transaction as its first argument and every read takes none, so a read cannot see the caller's own uncommitted write. That is why issueToken answers with what it wrote.

Nothing is notified when a User logs in, changes a password or is revoked. No Signal is emitted and no Handler wakes, so a deployment that wants one emits it itself inside the same transaction.

start and stop do nothing. A Token outlives a shutdown, being a row and the database's own clock, and nothing reaps an expired one.

Type Declaration

issueToken()
ts
issueToken<TSchema>(tx: Handle<TSchema>, user: string): Promise<IssuedToken>;

Issues a Token to a User who presented nothing, and answers what a login answers.

This is how a deployment adds a login of its own without writing an Auth. Write a route on the Public server, establish identity however you like, and call this. What comes back is an ordinary Token, and nothing downstream can tell how it was obtained.

The User needs no password, and their Token is not a lesser Token. It reads on the caller's transaction, so one transaction can create a User and hand them a Token. It throws when no User has that id.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

user

string

Returns

Promise<IssuedToken>

revoke()
ts
revoke<TSchema>(tx: Handle<TSchema>, user: string): Promise<void>;

Revokes every Token of one User, so that none of them works again.

The revocation DELETE /auth/tokens performs, reachable without HTTP. Nothing removes a User, so this is the closest thing to shutting one out, and it is not close: they keep their password, which mints a new Token, so replace that too.

Idempotent, and it answers nothing, not even a count. The rows are deleted rather than marked, which is the only compaction that table gets.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

user

string

Returns

Promise<void>

setPassword()
ts
setPassword<TSchema>(
  tx: Handle<TSchema>,
  user: string,
  password: string
): Promise<void>;

Replaces a User's password, proving nothing: the whole of account recovery here.

An Operator sets a new password from their own code, having established out of band that it is right. It also gives a password to a User who had none, which is the only way a User gets a first one. PUT /auth/password is the self-service route, and that one wants the current password.

It revokes nothing, so to lock somebody out, replace the password and then call PasswordAuth.revoke, in that order. There is no bound on the length here: the empty string stores like any other and leaves a User who cannot log in. It reads the User on the caller's transaction, so one transaction can create a User and give them a password. It throws when no User has that id.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

user

string

password

string

Returns

Promise<void>

start()
ts
start(): Promise<void>;
Returns

Promise<void>

stop()
ts
stop(): Promise<void>;
Returns

Promise<void>


PasswordAuthOptions

ts
type PasswordAuthOptions = {
  readonly db: Db;
  readonly publicServer: {
    readonly fastify: FastifyInstance;
    readonly requireUser: preHandlerAsyncHookHandler;
    registerAuth: (auth: Auth) => void;
  };
  readonly scrypt?: ScryptParameters;
  readonly tokenTtl: number;
  readonly users: Users;
};

Properties

db
ts
readonly db: Db;
publicServer
ts
readonly publicServer: {
  readonly fastify: FastifyInstance;
  readonly requireUser: preHandlerAsyncHookHandler;
  registerAuth: (auth: Auth) => void;
};

Where the four routes go, at /auth, and the server this registers itself with as an Auth.

Both acts happen in the constructor, so an entry point performs no wiring. Registration order is the order the server asks the schemes in, and it is the order they are named in a 401.

The three routes that act as somebody take this server's own requireUser, so a deployment running a second scheme can change a password over that scheme too.

Structural: anything carrying a Fastify instance, a registerAuth and a requireUser satisfies it, which is what serverComponent answers with.

fastify
ts
readonly fastify: FastifyInstance;
requireUser
ts
readonly requireUser: preHandlerAsyncHookHandler;
registerAuth()
ts
registerAuth(auth: Auth): void;
Parameters
auth

Auth

Returns

void

scrypt?
ts
readonly scrypt?: ScryptParameters;

What a password derivation costs. Defaults to OWASP's 32 MiB row, around 200ms of one core.

Old digests do not follow it. Each digest carries the parameters it was written under and verifies at those, so raising this leaves every stored password working and there is no rehash on login. The cost is paid on every login, and nothing here rate limits one.

tokenTtl
ts
readonly tokenTtl: number;

How long an issued Token lives, in milliseconds.

No default. A long lifetime means fewer logins and a longer window for a stolen Token, and only the deployment knows which side of that trade it is on.

It is not per-Token: every Token this component issues gets this lifetime, and one that never expires is unrepresentable.

users
ts
readonly users: Users;

Answers with the User record an authenticated request names, and with nothing else.

An Auth reports a User rather than an id, because the server that walks the Auths is built before any component and can resolve nothing itself. Construct Users first.


ScryptParameters

ts
type ScryptParameters = {
  readonly blockSize: number;
  readonly logN: number;
  readonly parallelism: number;
};

What a scrypt derivation costs, as the Operator states it and as each digest records it.

logN rather than N, because the parameter must be a power of two and every published recommendation is written that way. The other two are scrypt's own r and p, spelled out.

Properties

blockSize
ts
readonly blockSize: number;

scrypt's r. With logN it decides how much memory the derivation needs.

logN
ts
readonly logN: number;

log₂ of the CPU/memory cost. Memory is 128 · 2^logN · blockSize bytes.

parallelism
ts
readonly parallelism: number;

scrypt's p. Node runs the passes serially, so this multiplies the time.

Functions

createPasswordAuth()

ts
function createPasswordAuth(options: PasswordAuthOptions): PasswordAuth;

Builds the Password Auth component, registers its route group at /auth on the Public server, and registers itself with that server as an Auth.

Nothing here connects, listens or applies DDL.

Parameters

options

PasswordAuthOptions

Returns

PasswordAuth

Throws

If tokenTtl is not a positive number of milliseconds.

Throws

If a scrypt parameter is not a positive integer, or logN is above 20.