@nxgt/janus-hono 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,11 +47,11 @@ declarations import without extensions, so `nodenext` is not supported.
47
47
  | Export | What it is |
48
48
  | --- | --- |
49
49
  | `session(auth, options?)` | Middleware. Reads who the request belongs to — `auth.authenticate(c.req.raw)` — and sets `c.var.user` and `c.var.session`, `null` for an anonymous request. `{ required: true }` answers an anonymous request 401 and types `c.var.user` as never `null`. `{ type: 'staff' }` treats a user of any other type as anonymous. Sends a renewed session's cookie again |
50
- | `sendSession(c, auth, signedIn)` | Appends the session cookie to the response — after `signUp`, `signIn`, or anything that answered `{ token, session, user }` — and answers the user |
50
+ | `sendSession(c, auth, signedIn)` | Appends the session cookie to the response — after `signUp`, `signIn`, `secondFactor.confirm`, or anything that answered `{ token, session, user }` — and answers the user. With a second factor configured, narrow `signIn`'s answer on `status` first |
51
51
  | `signOut(c, auth)` | Revokes the session the request presents and clears the cookie, whatever the answer. `false` when the request presented no session, or an unknown one |
52
52
  | `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)`; anything else to `fallback`, or to Hono's own handling. `report(error, c)` sees every one answered 5xx first — `STORE_FAILED` and the like, for your logs |
53
- | `statusOf(code)` | The status a code deserves: `STORE_FAILED` 503, `CREDENTIALS_INVALID` 401, `USER_INACTIVE` 403, `LOGIN_TAKEN` 409, … Exhaustive over `JanusErrorCode` |
54
- | `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID` and `minLength` for `PASSWORD_TOO_SHORT` — what the client can act on, and nothing else |
53
+ | `statusOf(code)` | The status a code deserves: `STORE_FAILED` 503, `CREDENTIALS_INVALID` and `CODE_INVALID` 401, `USER_INACTIVE` 403, `LOGIN_TAKEN`, `SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` 409, … Exhaustive over `JanusErrorCode` |
54
+ | `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT` and `attemptsLeft` for any `CODE_INVALID` that carries it — `secondFactor.confirm`'s and `signInCode.confirm`'s — what the client can act on, and nothing else |
55
55
  | `SessionOptions<Type>` | `{ type?, required? }`, the options of `session()` — for a wrapper of your own |
56
56
  | `SessionEnv<typeof auth, Type?, Required?>` | The `Env` `session()` sets, for `new Hono<SessionEnv<typeof auth>>()` |
57
57
  | `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
@@ -63,6 +63,44 @@ declarations import without extensions, so `nodenext` is not supported.
63
63
 
64
64
  The whole status table is in [`@nxgt/janus`'s errors guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md).
65
65
 
66
+ ## Second factor
67
+
68
+ With `janus({ secondFactor })`, `signIn` answers a session or a challenge.
69
+ Narrow on `status` before `sendSession`, keep the challenge in a cookie for
70
+ the code route, and let `confirm` open the session:
71
+
72
+ ```ts
73
+ import { janusErrors, sendSession } from '@nxgt/janus-hono';
74
+ import { Hono } from 'hono';
75
+ import { getCookie, setCookie } from 'hono/cookie';
76
+ import { auth } from './auth'; // janus({ …, secondFactor: { issuer, keys } })
77
+
78
+ const app = new Hono()
79
+ .post('/sign-in', async (c) => {
80
+ const result = await auth.signIn(await c.req.json());
81
+ if (result.status === 'secondFactor') {
82
+ setCookie(c, 'sign-in-challenge', result.challenge, {
83
+ path: '/sign-in', httpOnly: true, secure: true, sameSite: 'Strict', expires: result.expiresAt,
84
+ });
85
+ return c.json({ next: 'code' });
86
+ }
87
+ return c.json({ id: sendSession(c, auth, result).id });
88
+ })
89
+ .post('/sign-in/code', async (c) => {
90
+ const { code } = await c.req.json();
91
+ const challenge = getCookie(c, 'sign-in-challenge') ?? '';
92
+ const user = sendSession(c, auth, await auth.secondFactor.confirm(challenge, code));
93
+ return c.json({ id: user.id });
94
+ });
95
+
96
+ app.onError(janusErrors()); // a wrong code: 401 { code: 'CODE_INVALID', attemptsLeft: 3 }
97
+ ```
98
+
99
+ `janusErrors()` answers `CODE_INVALID` 401 with `attemptsLeft` in the body,
100
+ `TOKEN_*` 400 — sign in again — and `SECOND_FACTOR_NOT_ENROLLED` and
101
+ `SECOND_FACTOR_ACTIVE` 409. The enrolment routes and a bearer client's
102
+ sign-in are in [the routes guide](docs/guide/routes.md#a-second-factor).
103
+
66
104
  ## Permissions
67
105
 
68
106
  `permission()` guards a route: the object is loaded once, checked, and handed
@@ -133,6 +171,16 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
133
171
 
134
172
  ## Traps
135
173
 
174
+ - **With a second factor, `sendSession(c, auth, await auth.signIn(…))` does
175
+ not compile.** A challenge has no session to send. Narrow first:
176
+ `if (result.status === 'secondFactor') …`.
177
+ - **A 401 is not always anonymous.** `session({ required: true })` answers
178
+ 401 with no body; a wrong second-factor code is 401 with
179
+ `{ code: 'CODE_INVALID', attemptsLeft }`. Read the body before sending the
180
+ visitor to the sign-in page.
181
+ - **A `janus()` without keys signing in a user with an active factor is a
182
+ 500.** It throws a `TypeError`, not a `JanusError`, so `janusErrors()`
183
+ hands it to `fallback`. Give every instance the same `secondFactor`.
136
184
  - **Without `app.onError(janusErrors())`, an outage is Hono's 500.** Not a
137
185
  401 — `session()` never turns a failure into an anonymous request — but not
138
186
  the 503 that tells a client to retry either.
@@ -155,7 +203,7 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
155
203
  - **`janusErrors()` logs nothing by itself.** A `STORE_FAILED` is answered 503
156
204
  without a line in your logs unless you pass `report` —
157
205
  `janusErrors({ report: (error) => logger.error(error) })`.
158
- - **`bodyOf` never carries `reason`, `login` or a cause.** `CREDENTIALS_INVALID`
206
+ - **`bodyOf` never carries `reason`, `login`, a challenge or a cause.** `CREDENTIALS_INVALID`
159
207
  says one thing for an unknown login, a missing password and a wrong one, so a
160
208
  response cannot tell which users exist. Log the error before you answer it
161
209
  if you need the reason: `janusErrors` is a function of `(error, c)` you can
@@ -175,7 +223,7 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
175
223
 
176
224
  ## Documentation
177
225
 
178
- - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, guarded routes
226
+ - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor and of a code sent by e-mail, guarded routes
179
227
  - [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
180
228
  - [Roadmap](docs/roadmap.md) — what is next, and what is not planned
181
229
 
package/dist/errors.d.ts CHANGED
@@ -11,13 +11,15 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status';
11
11
  export declare function statusOf(code: JanusErrorCode): ContentfulStatusCode;
12
12
  /**
13
13
  * The body a refusal is answered with: its `code`, and only what the client
14
- * can act on — the fields that failed the schema, the policy's minimum. Never
15
- * `reason`, `login`, a hash prefix or a cause: those are for your logs.
14
+ * can act on — the fields that failed the schema, the policy's minimum, the
15
+ * attempts a challenge has left. Never `reason`, `login`, a hash prefix or a
16
+ * cause: those are for your logs.
16
17
  */
17
18
  export declare function bodyOf(error: JanusError): {
18
19
  readonly code: JanusErrorCode;
19
20
  readonly issues?: JanusError['issues'];
20
21
  readonly minLength?: number;
22
+ readonly attemptsLeft?: number;
21
23
  };
22
24
  /** What `janusErrors()` takes. */
23
25
  export interface JanusErrorsOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAElD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,CA2BnE;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG;IAC1C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B,CAWA;AAgBD,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,YAAY,CAQ1E"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAElD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,CA+BnE;AAED;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG;IAC1C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAC/B,CAeA;AAgBD,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,YAAY,CAQ1E"}
package/dist/index.js CHANGED
@@ -116,7 +116,11 @@ function statusOf(code) {
116
116
  case "TOKEN_STALE":
117
117
  return 400;
118
118
  case "CREDENTIALS_INVALID":
119
+ case "CODE_INVALID":
119
120
  return 401;
121
+ case "SECOND_FACTOR_NOT_ENROLLED":
122
+ case "SECOND_FACTOR_ACTIVE":
123
+ return 409;
120
124
  case "USER_INACTIVE":
121
125
  return 403;
122
126
  case "UNSUPPORTED":
@@ -131,6 +135,8 @@ function bodyOf(error) {
131
135
  return { code: error.code, issues: error.issues ?? [] };
132
136
  case "PASSWORD_TOO_SHORT":
133
137
  return error.minLength === undefined ? { code: error.code } : { code: error.code, minLength: error.minLength };
138
+ case "CODE_INVALID":
139
+ return error.attemptsLeft === undefined ? { code: error.code } : { code: error.code, attemptsLeft: error.attemptsLeft };
134
140
  default:
135
141
  return { code: error.code };
136
142
  }
@@ -177,5 +183,5 @@ export {
177
183
  statusOf
178
184
  };
179
185
 
180
- //# debugId=93D6634A3905348664756E2164756E21
186
+ //# debugId=C2DF5327C05EE7CF64756E2164756E21
181
187
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -6,9 +6,9 @@
6
6
  "import type { MiddlewareHandler } from 'hono';\n\n/** What `provide()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Instances {\n\treadonly auth?: object;\n\treadonly access?: object;\n}\n\n/**\n * The middleware that puts the instances on the context — `c.var.auth`, and\n * `c.var.access` — so a route writes users and tuples through them rather\n * than through a module import: `c.var.access.grant(record, 'owners', user)`.\n *\n * Only what is passed is set, and typed.\n */\nexport function provide<const I extends Instances>(\n\tinstances: I,\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { [K in keyof I & keyof Instances]: I[K] };\n}> {\n\treturn async (c, next) => {\n\t\tif (instances.auth !== undefined) c.set('auth', instances.auth);\n\t\tif (instances.access !== undefined) c.set('access', instances.access);\n\t\tawait next();\n\t};\n}\n",
7
7
  "import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * ```ts\n * const user = sendSession(c, auth, await auth.signIn({ email, password }));\n * return c.json({ id: user.id }); // the token is in the cookie, never in the body\n * ```\n */\nexport function sendSession<U>(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: {\n\t\treadonly token: string;\n\t\treadonly session: Session;\n\t\treadonly user: U;\n\t},\n): U {\n\tc.header(\n\t\t'Set-Cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t\t{\n\t\t\tappend: true,\n\t\t},\n\t);\n\treturn signedIn.user;\n}\n\n/**\n * Revokes the session the request presents and clears the cookie — cleared\n * whatever the answer, so a browser holding a stale cookie drops it too.\n * `false` when the request presented no session, or an unknown one.\n */\nexport async function signOut(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(c.req.raw);\n\tc.header('Set-Cookie', auth.cookie.clear(), { append: true });\n\treturn revoked;\n}\n",
8
8
  "import type { Session } from '@nxgt/janus';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport {\n\ttype Awaitable,\n\ttype ObjectData,\n\ttype OptionsArgs,\n\tpermission,\n} from './permission';\nimport { provide } from './provide';\nimport {\n\ttype Auth,\n\ttype SessionEnv,\n\ttype SessionOptions,\n\tsendSession,\n\tsession,\n\tsignOut,\n\ttype UserOfAuth,\n} from './session';\n\n/** `session(auth, options?)`, with `auth` bound: the same three forms. */\nexport interface BoundSession<A> {\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T> & { readonly required: true },\n\t): MiddlewareHandler<SessionEnv<A, T, true>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions?: SessionOptions<T> & { readonly required?: false },\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T>,\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n}\n\n/** `permission(access, …)`, with `access` bound. */\nexport type BoundPermission<C extends ModelConfig> = <\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n) => MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\n\n/** The functions of this package that take `auth`, with `auth` bound. */\nexport interface BoundAuth<A> {\n\treadonly session: BoundSession<A>;\n\t/** `sendSession(c, auth, signedIn)`: sets the cookie, answers the user. */\n\treadonly sendSession: <U>(\n\t\tc: Context,\n\t\tsignedIn: {\n\t\t\treadonly token: string;\n\t\t\treadonly session: Session;\n\t\t\treadonly user: U;\n\t\t},\n\t) => U;\n\t/** `signOut(c, auth)`. */\n\treadonly signOut: (c: Context) => Promise<boolean>;\n}\n\n/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Bindable {\n\treadonly auth?: Auth<{ readonly type: string }>;\n\t/** What `permission()` takes: a `permissions()` instance, or its `can`. */\n\treadonly access?: { readonly can: unknown };\n}\n\n/**\n * What `bindJanus(instances)` answers: `provide()` always; `session`,\n * `sendSession` and `signOut` with `auth`; `permission` with `access`.\n */\nexport type Bound<I extends Bindable> = {\n\t/** `provide(instances)`: every instance bound, on the context. */\n\treadonly provide: () => MiddlewareHandler<{\n\t\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\t\tVariables: { [K in keyof I & keyof Bindable]: I[K] };\n\t}>;\n} & (I['auth'] extends Auth<{ readonly type: string }>\n\t? BoundAuth<I['auth']>\n\t: unknown) &\n\t(I['access'] extends Pick<Permissions<infer C>, 'can'>\n\t\t? { readonly permission: BoundPermission<C> }\n\t\t: unknown);\n\n/**\n * This package's functions with the instances bound once, so no route\n * repeats them:\n *\n * ```ts\n * const j = bindJanus({ auth, access });\n * const app = new Hono()\n * .use(j.session(), j.provide())\n * .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));\n * ```\n *\n * The same functions, typed the same way: `j.session({ required: true })` is\n * `session(auth, { required: true })`. Only what is given is bound — a\n * `permission` without `access` is absent from the type.\n */\nexport function bindJanus<const I extends Bindable>(instances: I): Bound<I> {\n\tconst { auth, access } = instances;\n\tconst bound: Record<string, unknown> = {\n\t\tprovide: () => provide(instances),\n\t};\n\tif (auth !== undefined) {\n\t\tbound.session = (options: SessionOptions<string> = {}) =>\n\t\t\tsession(auth, options);\n\t\tbound.sendSession = (\n\t\t\tc: Context,\n\t\t\tsignedIn: Parameters<typeof sendSession>[2],\n\t\t) => sendSession(c, auth, signedIn);\n\t\tbound.signOut = (c: Context) => signOut(c, auth);\n\t}\n\tif (access !== undefined) {\n\t\tconst guard = permission as unknown as (\n\t\t\taccess: object,\n\t\t\t...rest: unknown[]\n\t\t) => MiddlewareHandler;\n\t\tbound.permission = (...rest: unknown[]) => guard(access, ...rest);\n\t}\n\treturn bound as Bound<I>;\n}\n",
9
- "import { JanusError, type JanusErrorCode } from '@nxgt/janus';\nimport type { Context, ErrorHandler } from 'hono';\nimport type { HTTPException } from 'hono/http-exception';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * The status each code deserves. Exhaustive: a code added to `@nxgt/janus`\n * stops this file compiling instead of answering `undefined`.\n *\n * `STORE_FAILED` is 503 and nothing else — **an outage is not a negative\n * answer**, and a 401 or a 404 would tell every user they do not exist.\n */\nexport function statusOf(code: JanusErrorCode): ContentfulStatusCode {\n\tswitch (code) {\n\t\tcase 'STORE_FAILED':\n\t\t\treturn 503;\n\t\tcase 'NOT_FOUND':\n\t\t\treturn 404;\n\t\tcase 'LOGIN_TAKEN':\n\t\tcase 'VERSION_CONFLICT':\n\t\t\treturn 409;\n\t\tcase 'USER_INVALID':\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\tcase 'HASH_UNSUPPORTED':\n\t\tcase 'INVALID_CURSOR':\n\t\tcase 'TOKEN_UNKNOWN':\n\t\tcase 'TOKEN_SPENT':\n\t\tcase 'TOKEN_EXPIRED':\n\t\tcase 'TOKEN_STALE':\n\t\t\treturn 400;\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn 401;\n\t\tcase 'USER_INACTIVE':\n\t\t\treturn 403;\n\t\tcase 'UNSUPPORTED':\n\t\t\treturn 501;\n\t\tcase 'PERMISSION_DEPTH':\n\t\t\treturn 500;\n\t}\n}\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on — the fields that failed the schema, the policy's minimum. Never\n * `reason`, `login`, a hash prefix or a cause: those are for your logs.\n */\nexport function bodyOf(error: JanusError): {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n} {\n\tswitch (error.code) {\n\t\tcase 'USER_INVALID':\n\t\t\treturn { code: error.code, issues: error.issues ?? [] };\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\t\treturn error.minLength === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, minLength: error.minLength };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\n/**\n * Hono's own answer to an error it was not told about, copied from its\n * default handler: the response of an `HTTPException` — merged with the\n * headers the route set before throwing — and a logged 500 for anything else.\n */\nfunction honoDefault(error: Error | HTTPException, c: Context): Response {\n\tif ('getResponse' in error) {\n\t\tconst response = error.getResponse();\n\t\treturn c.newResponse(response.body, response);\n\t}\n\tconsole.error(error);\n\treturn c.text('Internal Server Error', 500);\n}\n\n/** What `janusErrors()` takes. */\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED`,\n\t * `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the\n\t * client — before it is answered. Its `slot`, `operation`, `reason` and\n\t * `cause` are for your logs; the body never carries them.\n\t *\n\t * **It cannot stop the answer**: one that throws, or rejects, is a\n\t * `process.emitWarning`, and the 503 is sent all the same.\n\t */\n\treadonly report?: (error: JanusError, c: Context) => unknown;\n\t/** Every error that is not a `JanusError`. Hono's own handling when absent. */\n\treadonly fallback?: ErrorHandler;\n}\n\n/**\n * An `app.onError` handler: every `JanusError` answered with its status and\n * `bodyOf(error)`; anything else handed to `fallback` — Hono's own behaviour\n * when absent.\n *\n * Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503\n * here too, never a 403.\n *\n * ```ts\n * app.onError(janusErrors({ report: (error) => logger.error(error) }));\n * ```\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): ErrorHandler {\n\tconst { report, fallback = honoDefault } = options;\n\treturn (error, c) => {\n\t\tif (!(error instanceof JanusError)) return fallback(error, c);\n\t\tconst status = statusOf(error.code);\n\t\tif (status >= 500 && report !== undefined) reportSafely(report, error, c);\n\t\treturn c.json(bodyOf(error), status);\n\t};\n}\n\n/** `report`, whose own failure is a warning — never a lost answer. */\nfunction reportSafely(\n\treport: NonNullable<JanusErrorsOptions['report']>,\n\terror: JanusError,\n\tc: Context,\n): void {\n\tconst warn = (failure: unknown) =>\n\t\tprocess.emitWarning(\n\t\t\t`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`,\n\t\t);\n\ttry {\n\t\tconst reported = report(error, c);\n\t\tif (reported instanceof Promise) reported.then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n"
9
+ "import { JanusError, type JanusErrorCode } from '@nxgt/janus';\nimport type { Context, ErrorHandler } from 'hono';\nimport type { HTTPException } from 'hono/http-exception';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * The status each code deserves. Exhaustive: a code added to `@nxgt/janus`\n * stops this file compiling instead of answering `undefined`.\n *\n * `STORE_FAILED` is 503 and nothing else — **an outage is not a negative\n * answer**, and a 401 or a 404 would tell every user they do not exist.\n */\nexport function statusOf(code: JanusErrorCode): ContentfulStatusCode {\n\tswitch (code) {\n\t\tcase 'STORE_FAILED':\n\t\t\treturn 503;\n\t\tcase 'NOT_FOUND':\n\t\t\treturn 404;\n\t\tcase 'LOGIN_TAKEN':\n\t\tcase 'VERSION_CONFLICT':\n\t\t\treturn 409;\n\t\tcase 'USER_INVALID':\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\tcase 'HASH_UNSUPPORTED':\n\t\tcase 'INVALID_CURSOR':\n\t\tcase 'TOKEN_UNKNOWN':\n\t\tcase 'TOKEN_SPENT':\n\t\tcase 'TOKEN_EXPIRED':\n\t\tcase 'TOKEN_STALE':\n\t\t\treturn 400;\n\t\tcase 'CREDENTIALS_INVALID':\n\t\tcase 'CODE_INVALID':\n\t\t\treturn 401;\n\t\tcase 'SECOND_FACTOR_NOT_ENROLLED':\n\t\tcase 'SECOND_FACTOR_ACTIVE':\n\t\t\treturn 409;\n\t\tcase 'USER_INACTIVE':\n\t\t\treturn 403;\n\t\tcase 'UNSUPPORTED':\n\t\t\treturn 501;\n\t\tcase 'PERMISSION_DEPTH':\n\t\t\treturn 500;\n\t}\n}\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on — the fields that failed the schema, the policy's minimum, the\n * attempts a challenge has left. Never `reason`, `login`, a hash prefix or a\n * cause: those are for your logs.\n */\nexport function bodyOf(error: JanusError): {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n\treadonly attemptsLeft?: number;\n} {\n\tswitch (error.code) {\n\t\tcase 'USER_INVALID':\n\t\t\treturn { code: error.code, issues: error.issues ?? [] };\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\t\treturn error.minLength === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, minLength: error.minLength };\n\t\tcase 'CODE_INVALID':\n\t\t\treturn error.attemptsLeft === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, attemptsLeft: error.attemptsLeft };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\n/**\n * Hono's own answer to an error it was not told about, copied from its\n * default handler: the response of an `HTTPException` — merged with the\n * headers the route set before throwing — and a logged 500 for anything else.\n */\nfunction honoDefault(error: Error | HTTPException, c: Context): Response {\n\tif ('getResponse' in error) {\n\t\tconst response = error.getResponse();\n\t\treturn c.newResponse(response.body, response);\n\t}\n\tconsole.error(error);\n\treturn c.text('Internal Server Error', 500);\n}\n\n/** What `janusErrors()` takes. */\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED`,\n\t * `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the\n\t * client — before it is answered. Its `slot`, `operation`, `reason` and\n\t * `cause` are for your logs; the body never carries them.\n\t *\n\t * **It cannot stop the answer**: one that throws, or rejects, is a\n\t * `process.emitWarning`, and the 503 is sent all the same.\n\t */\n\treadonly report?: (error: JanusError, c: Context) => unknown;\n\t/** Every error that is not a `JanusError`. Hono's own handling when absent. */\n\treadonly fallback?: ErrorHandler;\n}\n\n/**\n * An `app.onError` handler: every `JanusError` answered with its status and\n * `bodyOf(error)`; anything else handed to `fallback` — Hono's own behaviour\n * when absent.\n *\n * Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503\n * here too, never a 403.\n *\n * ```ts\n * app.onError(janusErrors({ report: (error) => logger.error(error) }));\n * ```\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): ErrorHandler {\n\tconst { report, fallback = honoDefault } = options;\n\treturn (error, c) => {\n\t\tif (!(error instanceof JanusError)) return fallback(error, c);\n\t\tconst status = statusOf(error.code);\n\t\tif (status >= 500 && report !== undefined) reportSafely(report, error, c);\n\t\treturn c.json(bodyOf(error), status);\n\t};\n}\n\n/** `report`, whose own failure is a warning — never a lost answer. */\nfunction reportSafely(\n\treport: NonNullable<JanusErrorsOptions['report']>,\n\terror: JanusError,\n\tc: Context,\n): void {\n\tconst warn = (failure: unknown) =>\n\t\tprocess.emitWarning(\n\t\t\t`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`,\n\t\t);\n\ttry {\n\t\tconst reported = report(error, c);\n\t\tif (reported instanceof Promise) reported.then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n"
10
10
  ],
11
- "mappings": "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",
12
- "debugId": "93D6634A3905348664756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "C2DF5327C05EE7CF64756E2164756E21",
13
13
  "names": []
14
14
  }
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ give the detail.
5
5
 
6
6
  | Page | Read it when |
7
7
  | --- | --- |
8
- | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, sign-out and the e-mail flows as Hono routes, with one user type or several |
8
+ | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code, sign-out and the e-mail flows as Hono routes, with one user type or several |
9
9
  | [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
10
10
  | [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
11
11
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -138,6 +138,13 @@ app.post('/sign-in', async (c) => {
138
138
  holds the session token: it is in the `Set-Cookie`, and the body cannot leak
139
139
  it.
140
140
 
141
+ With `secondFactor` configured, `signIn` may answer a challenge instead of a
142
+ session: narrow on `status` before `sendSession` — see
143
+ [A second factor](#a-second-factor).
144
+
145
+ To sign in with no password, by a code sent to the user's e-mail, see
146
+ [A code sent by e-mail](#a-code-sent-by-e-mail).
147
+
141
148
  The refusals need no `try`: `janusErrors()` answers them.
142
149
 
143
150
  | Thrown | Answered |
@@ -161,6 +168,213 @@ return c.json({
161
168
  });
162
169
  ```
163
170
 
171
+ ## A second factor
172
+
173
+ With `janus({ secondFactor })`, `signIn` answers either a session or a
174
+ **challenge** — `{ status: 'secondFactor', challenge, expiresAt }` — and
175
+ `sendSession` only takes the first. **Narrow on `status` before
176
+ `sendSession`**: passing `signIn`'s answer straight in no longer compiles,
177
+ because a challenge has no `token` and no `session`.
178
+
179
+ ```ts
180
+ import { sendSession } from '@nxgt/janus-hono';
181
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
182
+
183
+ const CHALLENGE = 'sign-in-challenge';
184
+ const scope = { path: '/sign-in', httpOnly: true, secure: true, sameSite: 'Strict' } as const;
185
+
186
+ app.post('/sign-in', async (c) => {
187
+ const { email, password } = await c.req.json();
188
+ const result = await auth.signIn({ email, password });
189
+ if (result.status === 'secondFactor') {
190
+ // the challenge is a secret: a cookie for the code route only, never a URL or a log
191
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
192
+ return c.json({ next: 'code' });
193
+ }
194
+ const user = sendSession(c, auth, result);
195
+ return c.json({ id: user.id });
196
+ });
197
+
198
+ app.post('/sign-in/code', async (c) => {
199
+ const { code } = await c.req.json();
200
+ const challenge = getCookie(c, CHALLENGE);
201
+ if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
202
+ const user = sendSession(c, auth, await auth.secondFactor.confirm(challenge, code));
203
+ deleteCookie(c, CHALLENGE, scope);
204
+ return c.json({ id: user.id });
205
+ });
206
+ ```
207
+
208
+ A wrong code leaves the cookie in place, so the visitor types the next one.
209
+ `janusErrors()` answers what `confirm` throws:
210
+
211
+ | Thrown | Answered |
212
+ | --- | --- |
213
+ | `CODE_INVALID` | 401 `{ code, attemptsLeft }` — the attempts the challenge has left, `0` once the fifth wrong code spent it |
214
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED` | 400 `{ code }` — send the visitor back to the password |
215
+ | `SECOND_FACTOR_NOT_ENROLLED` | 409 `{ code }` — the factor was disabled since `signIn`: sign in again |
216
+ | `USER_INACTIVE` | 403 `{ code }` |
217
+
218
+ `attemptsLeft` is the only thing the body adds: which cause of
219
+ `CODE_INVALID` it was — a wrong code or a reused one — is not told.
220
+
221
+ A bearer client gets the challenge in the body instead, and sends it back
222
+ with the code:
223
+
224
+ ```ts
225
+ app.post('/api/sign-in', async (c) => {
226
+ const result = await auth.signIn(await c.req.json());
227
+ if (result.status === 'secondFactor') {
228
+ return c.json({ challenge: result.challenge, expiresAt: result.expiresAt });
229
+ }
230
+ return c.json({ token: result.token, expiresAt: result.session.expiresAt });
231
+ });
232
+ ```
233
+
234
+ ### Enrolling, activating, disabling
235
+
236
+ The user is signed in for these, so `session()` names them:
237
+
238
+ ```ts
239
+ app.post('/account/second-factor', session(auth, { required: true }), async (c) => {
240
+ const { secret, uri } = await auth.secondFactor.enroll(c.var.user);
241
+ c.header('Cache-Control', 'no-store'); // the secret is shown once, and never cached
242
+ return c.json({ secret, uri }); // the page renders uri as a QR code
243
+ });
244
+
245
+ app.post('/account/second-factor/activate', session(auth, { required: true }), async (c) => {
246
+ const { code } = await c.req.json();
247
+ await auth.secondFactor.activate(c.var.user, code);
248
+ return c.body(null, 204);
249
+ });
250
+
251
+ app.delete('/account/second-factor', session(auth, { required: true }), async (c) => {
252
+ // your policy: a session opened in the last five minutes, so its holder just proved themselves
253
+ if (Date.now() - c.var.session.authenticatedAt.getTime() > 5 * 60_000) {
254
+ return c.json({ error: 'signInAgain' }, 403);
255
+ }
256
+ await auth.secondFactor.disable(c.var.user);
257
+ return c.body(null, 204);
258
+ });
259
+ ```
260
+
261
+ | Thrown | Answered |
262
+ | --- | --- |
263
+ | `SECOND_FACTOR_ACTIVE` | 409 `{ code }` — `enroll` or `activate` on a factor already active |
264
+ | `SECOND_FACTOR_NOT_ENROLLED` | 409 `{ code }` — `activate` before `enroll` |
265
+ | `CODE_INVALID` | 401 `{ code }` — `activate` counts no attempts, so there is no `attemptsLeft` |
266
+
267
+ The key rotation, the attempts and the replay rules are
268
+ [`@nxgt/janus`'s second factor guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md).
269
+
270
+ ## A code sent by e-mail
271
+
272
+ A sign-in by e-mailed code is two routes, built like
273
+ [the second factor's](#a-second-factor): the first asks for a code and keeps
274
+ the **challenge** in a cookie scoped to the second, which takes the code and
275
+ opens the session. Sending the e-mail is yours.
276
+
277
+ ```ts
278
+ import { randomBytes } from 'node:crypto';
279
+ import { sendSession } from '@nxgt/janus-hono';
280
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
281
+
282
+ const CODE_CHALLENGE = 'sign-in-code';
283
+ const codeScope = { path: '/sign-in/email', httpOnly: true, secure: true, sameSite: 'Strict' } as const;
284
+
285
+ app.post('/sign-in/email', async (c) => {
286
+ const { email } = await c.req.json();
287
+ const issued = await auth.signInCode.request(email);
288
+ if (issued !== null) {
289
+ void mailer.send(issued.email, `Your sign-in code: ${issued.code}`); // the code only; not awaited
290
+ }
291
+ // the same answer either way: a decoy challenge when nobody holds the e-mail
292
+ const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
293
+ setCookie(c, CODE_CHALLENGE, challenge, { ...codeScope, maxAge: 600 });
294
+ return c.json({ next: 'code' }, 202);
295
+ });
296
+
297
+ app.post('/sign-in/email/code', async (c) => {
298
+ const { code } = await c.req.json();
299
+ const challenge = getCookie(c, CODE_CHALLENGE);
300
+ if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
301
+ const user = sendSession(c, auth, await auth.signInCode.confirm(challenge, code));
302
+ deleteCookie(c, CODE_CHALLENGE, codeScope);
303
+ return c.json({ id: user.id });
304
+ });
305
+ ```
306
+
307
+ **The request route answers the same whoever asked** — the same status, the
308
+ same body, and a cookie either way. `signInCode.request` answers `null` for
309
+ an address nobody holds, and a route that answered differently, or set no
310
+ cookie, would tell anyone which addresses have an account. The decoy is 32
311
+ random bytes, the shape of a real challenge; confirming it is
312
+ `TOKEN_UNKNOWN`. `maxAge: 600` is the default ten minutes of
313
+ `tokens.signInCode`, written out so both cookies match. Not awaiting the
314
+ mailer keeps the answer's time from telling either. Rate-limit this route
315
+ per address and per client: every call sends an e-mail.
316
+
317
+ The code route still tells a decoy apart: a wrong code against it is 400
318
+ `TOKEN_UNKNOWN`, against a real challenge 401 `CODE_INVALID` with
319
+ `attemptsLeft`. Where your users' addresses must stay secret, answer every
320
+ refusal of that route alike, before `janusErrors()` sees it:
321
+
322
+ ```ts
323
+ import { JanusError } from '@nxgt/janus';
324
+
325
+ app.onError((error, c) =>
326
+ error instanceof JanusError && error.code !== 'STORE_FAILED' && c.req.path === '/sign-in/email/code'
327
+ ? c.json({ code: 'CODE_INVALID' }, 401) // no attemptsLeft: it would tell a real challenge from a decoy
328
+ : answer(error, c),
329
+ );
330
+ ```
331
+
332
+ with `answer` the `janusErrors()` of [Wiring](#wiring).
333
+
334
+ The code goes in the e-mail, **the challenge never does**: not in the
335
+ e-mail, not in a link, not in a log.
336
+
337
+ A wrong code leaves the cookie in place, so the visitor types it again.
338
+ `janusErrors()` answers what `confirm` throws, with the statuses above:
339
+
340
+ | Thrown | Answered |
341
+ | --- | --- |
342
+ | `CODE_INVALID` | 401 `{ code, attemptsLeft }` — the attempts the challenge has left, `0` once the fifth wrong code spent it |
343
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED` | 400 `{ code }` — offer to send a new code |
344
+ | `TOKEN_STALE` | 400 `{ code }` — the e-mail changed since the code was sent: send a new one |
345
+ | `USER_INACTIVE` | 403 `{ code }` |
346
+
347
+ `confirm` marks the user's e-mail verified: the code reached the inbox.
348
+
349
+ ### With a second factor
350
+
351
+ With `janus({ secondFactor })`, a user whose factor is active gets a
352
+ **challenge** from `signInCode.confirm`, not a session — the code proves the
353
+ e-mail, not the factor — and passing its answer straight to `sendSession`
354
+ no longer compiles on a type with a password. Narrow on `status`, and hand
355
+ the challenge to the second factor's cookie, so
356
+ [`/sign-in/code`](#a-second-factor) takes it from there:
357
+
358
+ ```ts
359
+ app.post('/sign-in/email/code', async (c) => {
360
+ const { code } = await c.req.json();
361
+ const challenge = getCookie(c, CODE_CHALLENGE);
362
+ if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
363
+ const result = await auth.signInCode.confirm(challenge, code);
364
+ deleteCookie(c, CODE_CHALLENGE, codeScope);
365
+ if (result.status === 'secondFactor') {
366
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
367
+ return c.json({ next: 'secondFactor' });
368
+ }
369
+ const user = sendSession(c, auth, result);
370
+ return c.json({ id: user.id });
371
+ });
372
+ ```
373
+
374
+ A user type with no password has no second factor, so its `confirm` always
375
+ answers a session. The attempts, the lifetime and every refusal are
376
+ [`@nxgt/janus`'s sign-in code guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md).
377
+
164
378
  ## Sign-out
165
379
 
166
380
  ```ts
@@ -239,5 +453,7 @@ as staff replaces the patient's cookie.
239
453
 
240
454
  - [`@nxgt/janus` — sessions](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md) — lifespans, renewal, the cookie's attributes
241
455
  - [`@nxgt/janus` — errors](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md) — every code
456
+ - [`@nxgt/janus` — the second factor](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md) — keys, states, attempts
457
+ - [`@nxgt/janus` — sign-in codes](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md) — requesting, the challenge, attempts, passwordless types
242
458
  - [Guarded routes and writing tuples](permissions.md) — `permission()`, `provide()`
243
459
  - [Troubleshooting](../troubleshooting.md)
package/docs/roadmap.md CHANGED
@@ -34,6 +34,14 @@ Nothing yet.
34
34
 
35
35
  ## Shipped
36
36
 
37
+ Newest first; the [CHANGELOG](../CHANGELOG.md) holds every release.
38
+
39
+ - **The second factor's errors as statuses, v0.3.0** — `janusErrors()`
40
+ answers `CODE_INVALID` with 401 and `attemptsLeft` in the body, and
41
+ `SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` with 409. Needs
42
+ `@nxgt/janus` 0.5.0.
43
+ - **Examples and guides on `related` and `permits`, v0.2.0** — the model keys
44
+ of `@nxgt/janus` 0.2, which it now requires.
37
45
  - **The first release** — `session()`,
38
46
  `sendSession` (which answers the user), `signOut`, `permission()` and
39
47
  `byParam`, `provide()`, `janusErrors({ report, fallback })`, and
@@ -19,6 +19,7 @@ for what causes each.
19
19
  - [`Expected 5 arguments, but got 4`, on `permission()`](#expected-5-arguments-but-got-4-on-permission)
20
20
  - [`Type '{ id: string; }' is not assignable to type 'Awaitable<ObjectData<…> | null>'`](#type--id-string--is-not-assignable-to-type-awaitableobjectdata--null)
21
21
  - [`Property 'access' does not exist on type 'Readonly<ContextVariableMap & …>'`](#property-access-does-not-exist-on-type-readonlycontextvariablemap--)
22
+ - [`Argument of type 'SignInResult<…>' is not assignable to parameter of type …`, on `sendSession`](#argument-of-type-signinresult-is-not-assignable-to-parameter-of-type--readonly-token-string-readonly-session-session-readonly-user--)
22
23
 
23
24
  **Runtime**
24
25
  - [`500 Internal Server Error` for every refusal](#500-internal-server-error-for-every-refusal)
@@ -28,6 +29,8 @@ for what causes each.
28
29
  - [`401` with an empty body, for a signed-in user](#401-with-an-empty-body-for-a-signed-in-user)
29
30
  - [`503 {"code":"STORE_FAILED"}` on every route](#503-codestore_failed-on-every-route)
30
31
  - [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
32
+ - [`401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form](#401-codecode_invalidattemptsleftn-on-the-code-form) — a second factor's, or the code sent by e-mail
33
+ - [`409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`](#409-codesecond_factor_active-or-409-codesecond_factor_not_enrolled)
31
34
  - [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
32
35
  - [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
33
36
 
@@ -144,6 +147,36 @@ that instance: it sets, and types, only what it is given.
144
147
  const app = new Hono().use(session(auth), provide({ auth, access }));
145
148
  ```
146
149
 
150
+ ### `Argument of type 'SignInResult<…>' is not assignable to parameter of type '{ readonly token: string; readonly session: Session; readonly user: … }'`
151
+
152
+ Also: `Type 'SecondFactorRequired' is missing the following properties …: token, session, user`.
153
+
154
+ **When:** `sendSession(c, auth, await auth.signIn(...))`, once `janus()` is
155
+ given a `secondFactor`.
156
+
157
+ **Why:** `signIn` then answers a session, or `{ status: 'secondFactor',
158
+ challenge, expiresAt }` for a user whose second factor is active — and a
159
+ challenge is no session to send.
160
+
161
+ **Fix:** switch on `status`, and send the session `secondFactor.confirm`
162
+ answers on the next request.
163
+
164
+ ```ts
165
+ app.post('/sign-in', async (c) => {
166
+ const result = await auth.signIn(await c.req.json());
167
+ if (result.status === 'secondFactor') {
168
+ // never in a URL: the body of the code form, or a short-lived cookie
169
+ return c.json({ challenge: result.challenge, expiresAt: result.expiresAt });
170
+ }
171
+ return c.json(sendSession(c, auth, result));
172
+ });
173
+
174
+ app.post('/sign-in/code', async (c) => {
175
+ const { challenge, code } = await c.req.json();
176
+ return c.json(sendSession(c, auth, await auth.secondFactor.confirm(challenge, code)));
177
+ });
178
+ ```
179
+
147
180
  ## Runtime
148
181
 
149
182
  ### `TypeError: permission(): c.var.object is already set`
@@ -278,6 +311,74 @@ wiring fault, not the user's: they cannot act on the 400.
278
311
  janus({ …, hasher: bunHasher(), verifiers: [scryptHasher()] }); // old hashes still verify
279
312
  ```
280
313
 
314
+ ### `401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form
315
+
316
+ Also `401 {"code":"CODE_INVALID"}`, with no `attemptsLeft`, from
317
+ `secondFactor.activate`.
318
+
319
+ **When:** a route calling `secondFactor.confirm(challenge, code)` or
320
+ `secondFactor.activate(user, code)`, with a code that does not match or was
321
+ already used — or a route calling `signInCode.confirm(challenge, code)`, the
322
+ code sent by e-mail, with a code that does not match or is not six digits.
323
+
324
+ **Why:** `janusErrors()` answers a wrong code 401, like a wrong password. On
325
+ `confirm`, `attemptsLeft` is what the challenge has left of its five attempts;
326
+ at `0` it is spent, and the next call with it answers
327
+ `400 {"code":"TOKEN_SPENT"}`. If the code is the one the app shows, the cause
328
+ is a clock off by more than about 30 seconds, or a code used already — see
329
+ [`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#the-code-the-authenticator-app-shows-is-refused-with-code_invalid).
330
+
331
+ On `signInCode.confirm`, `attemptsLeft` counts the same five attempts, and
332
+ at `0` the challenge is spent too: the right code no longer works with it.
333
+
334
+ **Fix:** read `attemptsLeft` in the client: ask again while it is above `0`,
335
+ and go back to the sign-in form once it is `0`.
336
+
337
+ ```ts
338
+ const response = await fetch('/sign-in/code', { method: 'POST', body: JSON.stringify({ challenge, code }) });
339
+ if (response.status === 401) {
340
+ const { attemptsLeft } = await response.json();
341
+ if (attemptsLeft === 0) showSignInForm();
342
+ else showCodeForm(`Wrong code — ${attemptsLeft} attempts left`);
343
+ }
344
+ ```
345
+
346
+ For the code sent by e-mail, request a new code once it is `0` — a new
347
+ challenge, with five attempts of its own:
348
+
349
+ ```ts
350
+ const response = await fetch('/sign-in/email/code', { method: 'POST', body: JSON.stringify({ code }) });
351
+ if (response.status === 401) {
352
+ const { attemptsLeft } = await response.json();
353
+ if (attemptsLeft === 0) showEmailForm(); // POST /sign-in/email again: a new code
354
+ else showCodeForm(`Wrong code — ${attemptsLeft} attempts left`);
355
+ }
356
+ ```
357
+
358
+ ### `409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`
359
+
360
+ **When:** a route calling `secondFactor.enroll` or `secondFactor.activate`
361
+ answers `SECOND_FACTOR_ACTIVE` for a user whose factor is already on;
362
+ `secondFactor.activate` with no `enroll` before it, or `secondFactor.confirm`
363
+ after the factor was disabled, answers `SECOND_FACTOR_NOT_ENROLLED`.
364
+
365
+ **Why:** both say the factor is not in the state the call needs — a conflict
366
+ with the user's state, so 409, not 400. A `409 {"code":"VERSION_CONFLICT"}`
367
+ from the code form is the other 409: the same code submitted twice at once,
368
+ where the first call already opened the session.
369
+
370
+ **Fix:** read `c.var.user.hasSecondFactor` before offering "set up" or
371
+ "turn off"; after `SECOND_FACTOR_NOT_ENROLLED` on the code form, send the
372
+ visitor back to sign in. The causes of each are in
373
+ [`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#second-factor).
374
+
375
+ ```ts
376
+ app.post('/me/second-factor', session(auth, { required: true }), async (c) => {
377
+ if (c.var.user.hasSecondFactor) return c.json({ active: true });
378
+ return c.json(await auth.secondFactor.enroll(c.var.user)); // { secret, uri }
379
+ });
380
+ ```
381
+
281
382
  ### The browser never sends the cookie back
282
383
 
283
384
  **When:** `sendSession` answered a `Set-Cookie`, and the next request presents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus-hono",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Hono middleware for @nxgt/janus: the signed-in user on every request, the session cookie, routes guarded by a permission, and every error as its status",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,13 +49,13 @@
49
49
  ]
50
50
  },
51
51
  "devDependencies": {
52
- "@nxgt/janus": "^0.4.0",
52
+ "@nxgt/janus": "^0.6.0",
53
53
  "@types/bun": "^1.4.2",
54
54
  "hono": "^4.13.9",
55
55
  "zod": "^4.6.5"
56
56
  },
57
57
  "peerDependencies": {
58
- "@nxgt/janus": "^0.4.0",
58
+ "@nxgt/janus": "^0.6.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }