astro-altcha 0.2.0 → 0.4.0

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/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright 2026 Lukas Trombach
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -5,15 +5,19 @@
5
5
  ![NPM Version](https://img.shields.io/npm/v/astro-altcha)
6
6
 
7
7
 
8
- > **Warning** Early development. Expect bugs and frequent breaking changes.
8
+ > **Warning** Early development. Expect bugs and breaking changes.
9
9
 
10
- This is an [Astro integration](https://docs.astro.build/en/guides/integrations-guide/) that sets up altcha for Astro.
10
+ This is an [Astro integration](https://docs.astro.build/en/guides/integrations-guide/) that helps setting up altcha for Astro. It includes some optimizations and convenience features that should make it really easy to use altcha in your application. The main features are:
11
+
12
+ - automatic middleware and challenge endpoint creation
13
+ - preconfigured challenge storage to prevent replay attacks
14
+ - `AltchaForm` and `AltchaObfuscationWidget` Astro components, optimized for speed; only load altcha's javascript when it's actually needed
11
15
 
12
16
  ## Usage
13
17
 
14
18
  ### Prerequisites
15
19
 
16
- TODO:
20
+ Since `astro-altcha` requires middleware and an endpoint to create and verify challenges at runtime it does not work in a fully static build. At least Astro version 6 is required.
17
21
 
18
22
  ### Installation
19
23
 
@@ -59,9 +63,132 @@ export default defineConfig({
59
63
  });
60
64
  ```
61
65
 
66
+ 4. Set `ALTCHA_HMAC_SIGNATURE` in your local .env file and in your deployment environment. This is the signing key for your altcha challenges and should be kept secret. `astro-altcha` currently does not (and might never) support unsigned challenge generation so this value must be provided.
67
+
62
68
  ### Configuration
63
69
 
64
- TODO:configuration
70
+ #### Integration Options
71
+
72
+ - `defaultName`: the global default name of the `altcha-widget` or the name of the cookie when using cookie mode. Must contain the string "astro-altcha" (this is so that the middleware can find the challenge solution). Defaults to `astro-altcha`
73
+ - `challengePath`: the path under which the challenge creation API is made available. Defaults to `/api/altcha/challenge`
74
+ - `challengeOptions`: can be used to change challange algorithm and parameters. See [challenge creation](https://altcha.org/docs/integration/server/#challenge-generation) for more info
75
+ - `challengeExpiresAfterMinutes`: time in minutes for a challenge to remain valid. Defaults to 20m
76
+ - `setCookie`: set a cookie instead of using an invisible form field in `AltchaForm`. Can be set to `true` to use default options: `{ name: "astro-altcha", maxAge: 1200, path: "/", sameSite: "lax", secure: command !== "dev" }`
77
+ - `storage`: custom path for a storage module. See [Customize Storage](#customize-storage)
78
+
79
+ #### Customize sSorage
80
+
81
+ `astro-altcha` prevents replay attacks by always configuring a storage to remember already issued challenges. By default it uses `altcha-lib`'s `CappedMap`, but this can be changed by creating a `.ts/.js` file which default exports a custom storage implementation in `src/astro-altcha/storage.ts`. This path can be customized via the `storage` global option. For typings the library provides a `defineStorage` helper function.
82
+
83
+ ```ts
84
+ // src/astro-altcha/storage.ts
85
+ import { defineStorage } from "astro-altcha";
86
+
87
+ const storage = defineStorage({
88
+ get: (key: string) => console.log(`getting ${key}`),
89
+ set: (key: string, value: boolean) =>
90
+ console.log(`setting ${key} with ${value}`),
91
+ });
92
+ export default storage;
93
+ ````
94
+
95
+ This can be useful if you want to want to use an external storage provider such as redis or if you simply want to provide your own custom implementation.
96
+
97
+ ### Components
98
+
99
+ While you can just use the provided middleware and challenge handlers on their own, the easiest way to get started with `astro-altcha` are the bundled astro components. They are designed to work with the injected challenge endpoint and middleware without further configuration.
100
+
101
+ #### AltchaForm
102
+
103
+ A form component, which includes an `altcha-widget` and handles its initialisation for you.
104
+
105
+ ```astro
106
+ ---
107
+ import { AltchaForm } from "astro-altcha/components";
108
+ import { actions } from "astro:actions";
109
+ ---
110
+
111
+ <AltchaForm action={actions.submitForm}>
112
+ <input name="firstname">
113
+ <input name="lastname">
114
+ <button type="submit">Submit</button>
115
+ </AltchaForm>
116
+ ```
117
+
118
+ With the default integration configuration this will render a standard altcha-widget element on page load. The result of the verification can be easily checked via the Astro locals object either in an Astro action or any other route handler that runs after middleware.
119
+
120
+ ```ts
121
+ // src/actions/index.ts
122
+ import { type ActionAPIContext, defineAction } from "astro:actions";
123
+
124
+ export const server = {
125
+ submitForm: defineAction({
126
+ accept: "form",
127
+ handler: async (input, context: ActionAPIContext) =>
128
+ console.log("is verified?", context.locals.altcha.verified),
129
+ }),
130
+ };
131
+ ````
132
+
133
+ The `AltchaForm` component exposes the raw [Altcha widget configuration options](https://altcha.org/docs/integration/widget/#configuration), but includes a few properties for convenience:
134
+
135
+ ```astro
136
+ ---
137
+ import { AltchaForm } from "astro-altcha/components";
138
+ ---
139
+
140
+ <AltchaForm widget={
141
+ {
142
+ debug: true,
143
+ hydrationStrategy: "onload",
144
+ theme: "cyberpunk",
145
+ }
146
+ }>
147
+ ...
148
+ </AltchaForm>
149
+ ````
150
+
151
+ - the widget's debug mode can be enabled via the `debug` prop on the widget. This will call the widgets `configure` method for you after the widget script has been loaded in your browser
152
+ - the hydration strategy of the widget can be chosen via the `hydrationStrategy` property. By default the widget script is loaded when the page loads. Two other strategies are available: `onvisible` and `onfocus`. `onvisible` uses an `IntersectionObserver` to initialise the widget when the containing form enters the viewport. `onfocus` initialises when the user clicks into the form.
153
+ - the widget's [theme](https://altcha.org/docs/integration/widget/#themes-and-customization) can be set via the `theme` property on the widget. `astro-altcha` takes care of importing the required css files for you.
154
+
155
+ `AltchaForm` emits an `astro-altcha:init` event which fires right after the altcha widget was initialised and gives access to an instance of the contained `altcha-widget` via the details prop.
156
+
157
+ ```astro
158
+ ---
159
+ import { AltchaForm } from "astro-altcha/components";
160
+ ---
161
+
162
+ <AltchaForm name="my-form">...</AltchaForm>
163
+
164
+ <script>
165
+ const form = document.querySelector("form[name=my-form]") as HTMLFormElement;
166
+
167
+ form.addEventListener("astro-altcha:init", (event) => {
168
+ console.log("altcha widget initialised");
169
+ event.detail.widget.configure({...})
170
+ })
171
+ </script>
172
+ ````
173
+
174
+ #### AltchaObfuscationWidget
175
+
176
+ The widget can be used in the same way as described in the [obfuscation documentation](https://altcha.org/docs/integration/obfuscation/), with the advantage that `AltchaObfuscationWidget` takes care of obfuscating the data on the server for you. Just pass data via the `data` prop. Note that this follows the same rules as `altcha-widget`, so if you want to obfuscate a link, just pass the plain url; the widget will render it as an `a` element after deobfuscation.
177
+
178
+ The widget can be themed in the same way as the `AltchaForm` widget. By default its hydration strategy is set to `onvisible` and display to `floating`.
179
+
180
+ ```astro
181
+ ---
182
+ import { AltchaObfuscationWidget } from "astro-altcha/components";
183
+ ---
184
+
185
+ <AltchaObfuscationWidget data="https://astro.build" />
186
+
187
+ <!-- Trigger can be customized -->
188
+ <AltchaObfuscationWidget data="https://astro.build">
189
+ <button type="button">I am a custom reveal button</button>
190
+ </AltchaObfuscationWidget>
191
+ ```
65
192
 
66
193
  ## Contributing
67
194
 
@@ -70,7 +197,7 @@ This package is structured as a monorepo:
70
197
  - `playground` contains code for testing the package
71
198
  - `package` contains the actual package
72
199
 
73
- Install dependencies using pnpm:
200
+ Install dependencies using pnpm:
74
201
 
75
202
  ```bash
76
203
  pnpm i --frozen-lockfile
@@ -86,9 +213,8 @@ You can now edit files in `package`. Please note that making changes to those fi
86
213
 
87
214
  ## Licensing
88
215
 
89
- [MIT Licensed](https://github.com/TODO:/blob/main/LICENSE). Made with ❤️ by [TODO:](https://github.com/TODO:).
216
+ [MIT Licensed](https://codeberg.org/Trombach/astro-altcha/blob/main/LICENSE). Made with ❤️ by [Trombach](https://codeberg.org/Trombach).
90
217
 
91
218
  ## Acknowledgements
92
219
 
93
220
  - Created using [astro-integration-template](https://github.com/florian-lefebvre/astro-integration-template).
94
- - TODO:
@@ -1,6 +1,4 @@
1
1
  import { Algorithm, ResolvedAstroAltchaConfig } from "../types/index.js";
2
2
  import { DeriveKeyFunction } from "altcha/lib";
3
3
  import { APIRoute } from "astro";
4
- //#region src/api/altcha-challenge.d.ts
5
- export declare function createChallengeHandler(hmacSecret: string, config: ResolvedAstroAltchaConfig<Algorithm>, deriveKeyFunc: DeriveKeyFunction): APIRoute;
6
- //#endregion
4
+ export declare function createChallengeHandler(hmacSecret: string, { challengeOptions, challengeExpiresAfterMinutes, setCookie }: ResolvedAstroAltchaConfig<Algorithm>, deriveKeyFunc: DeriveKeyFunction): APIRoute;
@@ -1,20 +1,23 @@
1
1
  import { createChallenge } from "altcha-lib";
2
2
  import { deriveHmacKeySecret } from "altcha-lib/frameworks/shared";
3
3
 
4
- //#region src/api/altcha-challenge.ts
5
- function createChallengeHandler(hmacSecret, config, deriveKeyFunc) {
4
+ // src/api/altcha-challenge.ts
5
+ function createChallengeHandler(hmacSecret, { challengeOptions, challengeExpiresAfterMinutes, setCookie }, deriveKeyFunc) {
6
6
  return (async (context) => {
7
7
  if (context.cache.enabled) context.cache.set(false);
8
8
  const challenge = await createChallenge({
9
- ...config.challengeOptions,
10
- expiresAt: Math.floor(Date.now() / 1e3 + config.challengeExpiresAfterMinutes * 60),
9
+ ...challengeOptions,
10
+ expiresAt: Math.floor(Date.now() / 1e3 + challengeExpiresAfterMinutes * 60),
11
11
  deriveKey: deriveKeyFunc,
12
12
  hmacSignatureSecret: hmacSecret,
13
13
  hmacKeySignatureSecret: await deriveHmacKeySecret(hmacSecret)
14
14
  });
15
- return Response.json(challenge);
15
+ const body = {
16
+ configuration: setCookie ? { setCookie } : void 0,
17
+ ...challenge
18
+ };
19
+ return Response.json(body, { headers: { "Cache-Control": "no-store" } });
16
20
  });
17
21
  }
18
22
 
19
- //#endregion
20
23
  export { createChallengeHandler };
@@ -1,7 +1,8 @@
1
+ import { Algorithm, ResolvedAstroAltchaConfig } from "../types/index.js";
1
2
  import { DeriveKeyFunction } from "altcha-lib";
2
3
  import { z } from "astro/zod";
3
4
  import { MiddlewareHandler } from "astro";
4
- //#region src/api/altcha-middleware.d.ts
5
+ import { Store } from "altcha-lib/frameworks/types";
5
6
  declare const altchaPayloadSchema: z.ZodObject<{
6
7
  challenge: z.ZodObject<{
7
8
  parameters: z.ZodObject<{
@@ -23,6 +24,5 @@ declare const altchaPayloadSchema: z.ZodObject<{
23
24
  }, z.core.$loose>;
24
25
  }, z.core.$loose>;
25
26
  export type AltchaPayload = z.infer<typeof altchaPayloadSchema>;
26
- export declare function createAltchaMiddlewareHandler(hmacSecret: string, deriveKeyFunc: DeriveKeyFunction): MiddlewareHandler;
27
- //#endregion
27
+ export declare function createAltchaMiddlewareHandler(hmacSecret: string, deriveKeyFunc: DeriveKeyFunction, { setCookie }: ResolvedAstroAltchaConfig<Algorithm>, store: Store): MiddlewareHandler;
28
28
  export {};
@@ -1,9 +1,8 @@
1
- import { CappedMap } from "altcha-lib";
2
1
  import { deriveHmacKeySecret, verify } from "altcha-lib/frameworks/shared";
3
2
  import { z } from "astro/zod";
4
3
  import { base64ToString } from "uint8array-extras";
5
4
 
6
- //#region src/api/altcha-middleware.ts
5
+ // src/api/altcha-middleware.ts
7
6
  const altchaPayloadSchema = z.looseObject({
8
7
  challenge: z.looseObject({
9
8
  parameters: z.looseObject({
@@ -24,17 +23,19 @@ const altchaPayloadSchema = z.looseObject({
24
23
  time: z.number()
25
24
  })
26
25
  });
27
- const store = new CappedMap({ maxSize: 499 });
28
26
  const keyRegexp = /.*astro-altcha.*/;
29
- function createAltchaMiddlewareHandler(hmacSecret, deriveKeyFunc) {
27
+ function createAltchaMiddlewareHandler(hmacSecret, deriveKeyFunc, { setCookie }, store) {
30
28
  return async (context, next) => {
31
29
  if (context.request.method !== "POST") return next();
32
- if (!context.request.headers.get("content-type")?.includes("multipart/form-data") && !context.request.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) return next();
33
- const body = Object.fromEntries((await context.request.clone().formData()).entries());
34
- const key = Object.keys(body).find((key) => keyRegexp.test(key));
35
- if (!key) return next();
36
- const altchaBase64 = body?.[key]?.toString();
37
- if (!altchaBase64) return next();
30
+ let altchaBase64 = setCookie?.name ? context.cookies.get(setCookie.name)?.value : void 0;
31
+ if (!altchaBase64) {
32
+ if (!context.request.headers.get("content-type")?.includes("multipart/form-data") && !context.request.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) return next();
33
+ const body = Object.fromEntries((await context.request.clone().formData()).entries());
34
+ const key = Object.keys(body).find((key) => keyRegexp.test(key));
35
+ if (!key) return next();
36
+ altchaBase64 = body?.[key]?.toString();
37
+ if (!altchaBase64) return next();
38
+ }
38
39
  let parsed;
39
40
  try {
40
41
  parsed = JSON.parse(base64ToString(altchaBase64));
@@ -48,6 +49,7 @@ function createAltchaMiddlewareHandler(hmacSecret, deriveKeyFunc) {
48
49
  return next();
49
50
  }
50
51
  const result = await verify(altchaPayload, deriveKeyFunc, hmacSecret, await deriveHmacKeySecret(hmacSecret), store);
52
+ if (setCookie) context.cookies.delete(setCookie.name, { ...setCookie });
51
53
  if (result.error && !result.verification) {
52
54
  context.logger?.error(`Error verifying altcha payload: ${result.error}`);
53
55
  return next();
@@ -57,5 +59,4 @@ function createAltchaMiddlewareHandler(hmacSecret, deriveKeyFunc) {
57
59
  };
58
60
  }
59
61
 
60
- //#endregion
61
62
  export { createAltchaMiddlewareHandler };
@@ -1,3 +1 @@
1
- //#region src/api/challenge.d.ts
2
- export declare const GET: import("astro").APIRoute;
3
- //#endregion
1
+ export declare const GET: import("astro").APIRoute;
@@ -3,10 +3,9 @@ import { pickDeriveKeyFunc } from "./pickDeriveKey.js";
3
3
  import { getSecret } from "astro:env/server";
4
4
  import { config } from "astro-altcha:config";
5
5
 
6
- //#region src/api/challenge.ts
6
+ // src/api/challenge.ts
7
7
  const ALTCHA_HMAC_SIGNATURE = getSecret("ALTCHA_HMAC_SIGNATURE");
8
8
  if (!ALTCHA_HMAC_SIGNATURE) throw new Error("ALTCHA_HMAC_SIGNATURE not set");
9
9
  const GET = createChallengeHandler(ALTCHA_HMAC_SIGNATURE, config, await pickDeriveKeyFunc(config.challengeOptions.algorithm));
10
10
 
11
- //#endregion
12
11
  export { GET };
@@ -1,3 +1 @@
1
- //#region src/api/middleware.d.ts
2
- export declare const onRequest: any;
3
- //#endregion
1
+ export declare const onRequest: any;
@@ -3,12 +3,12 @@ import { pickDeriveKeyFunc } from "./pickDeriveKey.js";
3
3
  import { getSecret } from "astro:env/server";
4
4
  import { config } from "astro-altcha:config";
5
5
  import { defineMiddleware } from "astro:middleware";
6
+ import store from "astro-altcha:storage";
6
7
 
7
- //#region src/api/middleware.ts
8
+ // src/api/middleware.ts
8
9
  const ALTCHA_HMAC_SIGNATURE = getSecret("ALTCHA_HMAC_SIGNATURE");
9
10
  if (!ALTCHA_HMAC_SIGNATURE) throw new Error("ALTCHA_HMAC_SIGNATURE not set");
10
- const altchaMiddleware = defineMiddleware(createAltchaMiddlewareHandler(ALTCHA_HMAC_SIGNATURE, await pickDeriveKeyFunc(config.challengeOptions.algorithm)));
11
+ const altchaMiddleware = defineMiddleware(createAltchaMiddlewareHandler(ALTCHA_HMAC_SIGNATURE, await pickDeriveKeyFunc(config.challengeOptions.algorithm), config, store));
11
12
  const onRequest = altchaMiddleware;
12
13
 
13
- //#endregion
14
14
  export { onRequest };
@@ -1,4 +1,2 @@
1
1
  import { Algorithm } from "../types/index.js";
2
- //#region src/api/pickDeriveKey.d.ts
3
- export declare function pickDeriveKeyFunc(algorithm: Algorithm): Promise<typeof import("altcha-lib/algorithms/pbkdf2").deriveKey>;
4
- //#endregion
2
+ export declare function pickDeriveKeyFunc(algorithm: Algorithm): Promise<typeof import("altcha-lib/algorithms/pbkdf2").deriveKey>;
@@ -1,4 +1,4 @@
1
- //#region src/api/pickDeriveKey.ts
1
+ // src/api/pickDeriveKey.ts
2
2
  async function pickDeriveKeyFunc(algorithm) {
3
3
  switch (algorithm) {
4
4
  case "PBKDF2/SHA-256":
@@ -12,5 +12,4 @@ async function pickDeriveKeyFunc(algorithm) {
12
12
  }
13
13
  }
14
14
 
15
- //#endregion
16
15
  export { pickDeriveKeyFunc };
@@ -0,0 +1,3 @@
1
+ import { AltchaWidgetTheme, AstroAltchaHydrationStrategy } from "../types/index.js";
2
+ export declare function mountWithStrategy(strategy: AstroAltchaHydrationStrategy, element: HTMLElement, mount: () => void): void;
3
+ export declare function importTheme(theme?: AltchaWidgetTheme | null): Promise<void>;
@@ -0,0 +1,46 @@
1
+ // src/components/common.ts
2
+ function mountWithStrategy(strategy, element, mount) {
3
+ switch (strategy) {
4
+ case "onload":
5
+ mount();
6
+ return;
7
+ case "onfocus":
8
+ element.addEventListener("focusin", mount, { once: true });
9
+ return;
10
+ case "onvisible":
11
+ new IntersectionObserver((entries, observer) => {
12
+ if (!entries.some((entry) => entry.isIntersecting)) return;
13
+ observer.disconnect();
14
+ mount();
15
+ }, { rootMargin: "200px 0px" }).observe(element);
16
+ return;
17
+ }
18
+ }
19
+ async function importTheme(theme) {
20
+ if (!theme) return;
21
+ switch (theme) {
22
+ case "aqua":
23
+ await import("altcha/themes/aqua.min.css");
24
+ return;
25
+ case "business":
26
+ await import("altcha/themes/business.min.css");
27
+ return;
28
+ case "caramel":
29
+ await import("altcha/themes/caramel.min.css");
30
+ return;
31
+ case "cupcake":
32
+ await import("altcha/themes/cupcake.min.css");
33
+ return;
34
+ case "cyberpunk":
35
+ await import("altcha/themes/cyberpunk.min.css");
36
+ return;
37
+ case "lime":
38
+ await import("altcha/themes/lime.min.css");
39
+ return;
40
+ case "wireframe":
41
+ await import("altcha/themes/wireframe.min.css");
42
+ return;
43
+ }
44
+ }
45
+
46
+ export { importTheme, mountWithStrategy };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  import { integration } from "./integration.js";
2
+ import { Store } from "altcha-lib/frameworks/types";
3
+ export declare function defineStorage<T extends Store>(store: T): T;
2
4
  export { integration as default };
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { integration } from "./integration.js";
2
2
 
3
- //#region src/index.ts
3
+ // src/index.ts
4
4
  var src_default = integration;
5
+ function defineStorage(store) {
6
+ return store;
7
+ }
5
8
 
6
- //#endregion
7
- export { src_default as default };
9
+ export { src_default as default, defineStorage };
@@ -1,39 +1,46 @@
1
- import { Algorithm, AltchaWidgetName, AstroAltchaConfig } from "./types/index.js";
1
+ import { Algorithm, AstroAltchaConfig } from "./types/index.js";
2
2
  import { AstroIntegration } from "astro";
3
- //#region src/integration.d.ts
4
3
  export declare function integration(options?: AstroAltchaConfig): AstroIntegration;
5
- export declare function mergeDefaultConfig(options?: AstroAltchaConfig): {
4
+ export declare function mergeDefaultConfig(command: "dev" | "build" | "preview" | "sync", options?: AstroAltchaConfig): {
5
+ readonly setCookie?: {
6
+ domain?: string;
7
+ name: string;
8
+ maxAge: number;
9
+ path: string;
10
+ sameSite: "lax" | "strict" | "none";
11
+ secure: boolean;
12
+ };
6
13
  readonly challengeOptions: {
7
14
  readonly algorithm: "PBKDF2/SHA-256";
8
15
  readonly cost: number;
9
16
  readonly counter: number;
10
17
  } | {
11
18
  readonly algorithm: Exclude<Algorithm, "Argon2id" | "Scrypt">;
12
- readonly keyLength?: number;
13
- readonly keyPrefix?: string;
14
19
  counter: number;
15
20
  readonly counterMode?: "uint32" | "string";
16
21
  readonly data?: Record<string, string | number | boolean | null>;
17
22
  readonly hmacAlgorithm?: import("altcha-lib").HmacAlgorithm;
23
+ readonly keyLength?: number;
24
+ readonly keyPrefix?: string;
18
25
  readonly keyPrefixLength?: number;
19
26
  readonly memoryCost?: number;
20
27
  readonly parallelism?: number;
21
28
  cost: number;
22
29
  } | {
23
30
  readonly algorithm: "Argon2id" | "Scrypt";
24
- readonly keyLength?: number;
25
- readonly keyPrefix?: string;
26
31
  counter: number;
27
32
  readonly counterMode?: "uint32" | "string";
28
33
  readonly data?: Record<string, string | number | boolean | null>;
29
34
  readonly hmacAlgorithm?: import("altcha-lib").HmacAlgorithm;
35
+ readonly keyLength?: number;
36
+ readonly keyPrefix?: string;
30
37
  readonly keyPrefixLength?: number;
31
38
  readonly memoryCost: number;
32
39
  readonly parallelism?: number;
33
40
  cost: number;
34
41
  };
35
- readonly defaultName: AltchaWidgetName;
42
+ readonly storage?: string;
43
+ readonly defaultName: `${string}astro-altcha${string}`;
36
44
  readonly challengePath: string;
37
45
  readonly challengeExpiresAfterMinutes: number;
38
- };
39
- //#endregion
46
+ };
@@ -1,16 +1,17 @@
1
- import plugin from "./vite-plugin.js";
1
+ import plugin from "./vite-plugin-config.js";
2
+ import plugin$1 from "./vite-plugin-storage.js";
2
3
  import { randomInt } from "altcha-lib";
3
4
  import { envField } from "astro/config";
4
5
 
5
- //#region src/integration.ts
6
+ // src/integration.ts
6
7
  function integration(options = {}) {
7
- const config = mergeDefaultConfig(options);
8
8
  return {
9
9
  name: "astro-altcha",
10
10
  hooks: {
11
- "astro:config:setup": ({ injectRoute, updateConfig, addMiddleware }) => {
11
+ "astro:config:setup": ({ injectRoute, updateConfig, addMiddleware, command }) => {
12
+ const config = mergeDefaultConfig(command, options);
12
13
  updateConfig({
13
- vite: { plugins: [plugin(config)] },
14
+ vite: { plugins: [plugin(config), plugin$1(config.storage)] },
14
15
  env: { schema: { ALTCHA_HMAC_SIGNATURE: envField.string({
15
16
  context: "server",
16
17
  access: "secret"
@@ -30,11 +31,9 @@ function integration(options = {}) {
30
31
  injectTypes({
31
32
  filename: "types.d.ts",
32
33
  content: `declare module "astro-altcha:config" {
33
- export const config: Required<
34
- import("astro-altcha/types").ResolvedAstroAltchaConfig<
35
- import("astro-altcha/types").Algorithm
36
- >
37
- >;
34
+ export const config: import("astro-altcha/types").ResolvedAstroAltchaConfig<
35
+ import("astro-altcha/types").Algorithm
36
+ >
38
37
  }
39
38
 
40
39
  declare namespace App {
@@ -74,20 +73,30 @@ export {};
74
73
  }
75
74
  };
76
75
  }
77
- function mergeDefaultConfig(options = {}) {
76
+ function mergeDefaultConfig(command, options = {}) {
77
+ const defaultName = options.defaultName ?? "astro-altcha";
78
+ const challengeExpiresAfterMinutes = options.challengeExpiresAfterMinutes ?? 20;
79
+ const setCookie = !options.setCookie ? void 0 : {
80
+ name: defaultName,
81
+ maxAge: challengeExpiresAfterMinutes * 60,
82
+ path: "/",
83
+ sameSite: "lax",
84
+ secure: command !== "dev",
85
+ ...options.setCookie === true ? {} : options.setCookie
86
+ };
78
87
  return {
79
- defaultName: "astro-altcha",
80
- challengePath: "/api/altcha/challenge",
81
- challengeExpiresAfterMinutes: 20,
82
- ...options,
88
+ defaultName,
89
+ challengePath: options.challengePath ?? "/api/altcha/challenge",
90
+ challengeExpiresAfterMinutes,
91
+ ...!options.storage ? { storage: options.storage } : {},
83
92
  challengeOptions: {
84
93
  algorithm: "PBKDF2/SHA-256",
85
94
  cost: 5e3,
86
95
  counter: randomInt(5e3, 1e4),
87
96
  ...options.challengeOptions
88
- }
97
+ },
98
+ ...setCookie ? { setCookie } : {}
89
99
  };
90
100
  }
91
101
 
92
- //#endregion
93
102
  export { integration, mergeDefaultConfig };
@@ -1,6 +1,6 @@
1
1
  import { CreateChallengeOptions } from "altcha-lib/types";
2
2
  import { WidgetAttributes } from "altcha/types";
3
- //#region src/types/index.d.ts
3
+ import { SetCookieOptions } from "altcha/lib";
4
4
  export type AltchaWidgetName = `${string}astro-altcha${string}`;
5
5
  export type Algorithm = `PBKDF2/${Digest}` | "Argon2id" | "Scrypt" | Digest;
6
6
  type Digest = `SHA-${"256" | "384" | "512"}`;
@@ -8,35 +8,50 @@ export type AstroAltchaConfig = {
8
8
  /**
9
9
  * Default altcha-widget name. Must contain the string `astro-altcha`.
10
10
  *
11
- * Default: `astro-altcha`
11
+ * @default astro-altcha
12
12
  */
13
13
  defaultName?: AltchaWidgetName;
14
14
  /**
15
15
  * Path of the challenge API endpoint.
16
16
  *
17
- * Default: `/api/altcha/challenge`
17
+ * @default /api/altcha/challenge
18
18
  */
19
19
  challengePath?: string;
20
20
  /**
21
21
  * Challenge options for [Challange creation](https://altcha.org/docs/integration/server/#challenge-generation)
22
22
  *
23
- * Default:
24
- * ```
25
- * algorithm: "PBKDF2/SHA-256"
26
- * cost: 5_000
27
- * counter: randomInt(5_000, 10_000)
28
- * ```
23
+ * @default { algorithm: "PBKDF2/SHA-256", cost: 5_000, counter: randomInt(5_000, 10_000) }
24
+ *
29
25
  */
30
26
  challengeOptions?: WithOptional<AltchaChallengeOptions<Algorithm>, "cost">;
31
27
  /**
32
28
  * Challenge expiration in minutes
33
29
  *
34
- * Default: 20 min
30
+ * @default 20
35
31
  */
36
32
  challengeExpiresAfterMinutes?: number;
33
+ /**
34
+ * Use cookies instead of an invisible input field
35
+ *
36
+ * @default { name: "astro-altcha", maxAge: 1200, path: "/", sameSite: "lax", secure: command !== "dev" }
37
+ */
38
+ setCookie?: true | (SetCookieOptions & {
39
+ sameSite?: "lax" | "strict" | "none";
40
+ });
41
+ /**
42
+ * Path or file URL to the module containing a custom storage adapter.
43
+ *
44
+ * @default "./src/astro-altcha/storage.ts"
45
+ */
46
+ storage?: string | URL;
37
47
  };
38
- export type ResolvedAstroAltchaConfig<Algo extends Algorithm> = Required<AstroAltchaConfig> & {
48
+ export type ResolvedSetCookieOptions = WithRequired<SetCookieOptions & {
49
+ sameSite?: "lax" | "strict" | "none";
50
+ }, "name" | "maxAge" | "path" | "sameSite" | "secure">;
51
+ export type ResolvedAstroAltchaConfig<Algo extends Algorithm> = Omit<Required<AstroAltchaConfig>, "setCookie" | "storage" | "challengeOptions"> & {
39
52
  challengeOptions: AltchaChallengeOptions<Algo>;
53
+ setCookie?: ResolvedSetCookieOptions;
54
+ storage?: string | URL;
40
55
  };
41
56
  type WithOptional<T, K extends keyof T> = T extends unknown ? Omit<T, K> & Partial<Pick<T, K>> : never;
42
57
  type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P]; };
@@ -46,23 +61,48 @@ export type AltchaChallengeOptions<Algo extends Algorithm> = Algo extends "Argon
46
61
  } : OmitCreateChallengeOptions & {
47
62
  algorithm: Exclude<Algorithm, "Argon2id" | "Scrypt">;
48
63
  };
49
- export type AstroAltchaHydrationStrategy = "onfocus" | "onload" | "onhover";
50
- export type AltchaFormWidgetProps = {
51
- /** When the altcha-widget should be hydrated.
52
- *
53
- * `onfocus` - when the form is first focused
54
- *
55
- * `onload` - when the page first loads
64
+ export type AstroAltchaHydrationStrategy = "onfocus" | "onload" | "onvisible";
65
+ export type AltchaWidgetProps = {
66
+ /**
67
+ * When the altcha-widget should be hydrated.
68
+ * - onfocus` - when the form is first focused
69
+ * - `onload` - when the page first loads
70
+ * - `onvisible` - when the form becomes visible in the viewport (uses IntersectionObserver)
56
71
  *
57
- * `onhover` - when the form is first hovered
72
+ * @default onload
58
73
  */
59
74
  hydrationStrategy?: AstroAltchaHydrationStrategy;
75
+ /**
76
+ * Select a theme from https://altcha.org/docs/integration/widget-customization/.
77
+ */
78
+ theme?: AltchaWidgetTheme;
79
+ /**
80
+ * Enable debug mode for altcha widget.
81
+ */
60
82
  debug?: boolean;
61
- } & Omit<WidgetAttributes, "name">;
83
+ } & Omit<WidgetAttributes, "name" | "theme">;
84
+ export type AltchaWidgetTheme = "aqua" | "business" | "caramel" | "cupcake" | "cyberpunk" | "lime" | "wireframe";
62
85
  export type AltchaFormProps = {
63
- widget?: AltchaFormWidgetProps & {
86
+ widget?: AltchaWidgetProps & {
64
87
  name?: AltchaWidgetName;
65
88
  };
66
89
  } & astroHTML.JSX.FormHTMLAttributes;
67
- //#endregion
90
+ export type AltchaObfuscationProps = {
91
+ /**
92
+ * Plain data to be obfuscated.
93
+ *
94
+ * It follows the same rules as a plain altcha-widget, i.e. it recognizes links and automatically
95
+ * inserts the appropriate element on deobfuscation. You do not need to include markup.
96
+ *
97
+ */
98
+ data: string;
99
+ /**
100
+ * When the altcha-widget should be hydrated.
101
+ * - `onload` - when the page first loads
102
+ * - `onvisible` - when the widget becomes visible in the viewport (uses IntersectionObserver)
103
+ *
104
+ * @default onvisible
105
+ */
106
+ hydrationStrategy?: Exclude<AstroAltchaHydrationStrategy, "onfocus">;
107
+ } & Omit<AltchaWidgetProps, "hydrationStrategy">;
68
108
  export {};
@@ -1,6 +1,4 @@
1
1
  import { Algorithm, ResolvedAstroAltchaConfig } from "./types/index.js";
2
2
  import { AstroConfig } from "astro";
3
- //#region src/vite-plugin.d.ts
4
3
  declare const plugin: (config: ResolvedAstroAltchaConfig<Algorithm>) => NonNullable<AstroConfig["vite"]["plugins"]>[number];
5
- //#endregion
6
4
  export { plugin as default };
@@ -1,4 +1,4 @@
1
- //#region src/vite-plugin.ts
1
+ // src/vite-plugin-config.ts
2
2
  const virtualModuleId = "astro-altcha:config";
3
3
  const resolvedVirtualModuleId = `\0${virtualModuleId}`;
4
4
  const plugin = (config) => {
@@ -14,5 +14,4 @@ ${JSON.stringify(config)}`;
14
14
  };
15
15
  };
16
16
 
17
- //#endregion
18
17
  export { plugin as default };
@@ -0,0 +1,3 @@
1
+ import { AstroConfig } from "astro";
2
+ declare const plugin: (storagePath?: string | URL) => NonNullable<AstroConfig["vite"]["plugins"]>[number];
3
+ export { plugin as default };
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // src/vite-plugin-storage.ts
6
+ const virtualModuleId = "astro-altcha:storage";
7
+ const resolvedVirtualModuleId = `\0${virtualModuleId}`;
8
+ const plugin = (storagePath) => {
9
+ let root = process.cwd();
10
+ return {
11
+ name: "vite-plugin-altcha-storage",
12
+ configResolved(config) {
13
+ root = config.root;
14
+ },
15
+ resolveId(id) {
16
+ if (id !== virtualModuleId) return;
17
+ const found = (storagePath ? [path.resolve(root, storagePath instanceof URL ? fileURLToPath(storagePath) : storagePath)] : [
18
+ "ts",
19
+ "js",
20
+ "mts",
21
+ "mjs"
22
+ ].map((ext) => path.resolve(root, `./src/astro-altcha/storage.${ext}`))).find((candidate) => fs.existsSync(candidate));
23
+ if (!found && storagePath) throw new Error(`storage file not found at: ${storagePath}`);
24
+ return found ?? resolvedVirtualModuleId;
25
+ },
26
+ load(id) {
27
+ if (id === resolvedVirtualModuleId) return `import { CappedMap } from "altcha-lib";
28
+ export default new CappedMap({ maxSize: 499 });
29
+ `;
30
+ }
31
+ };
32
+ };
33
+
34
+ export { plugin as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-altcha",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Altcha integration for Astro",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -49,8 +49,9 @@
49
49
  "astro": "^6.0.0 || ^7.0.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@astrojs/check": "0.9.10",
53
- "tsdown": "0.23.1"
52
+ "@astrojs/check": "0.9.12",
53
+ "tsdown": "0.23.5",
54
+ "vite": "^8.3.4"
54
55
  },
55
56
  "dependencies": {
56
57
  "altcha": "^3.2.3",
@@ -60,7 +61,12 @@
60
61
  "scripts": {
61
62
  "dev": "tsdown --watch",
62
63
  "build": "tsdown",
64
+ "prets:check": "pnpm build",
63
65
  "ts:check": "pnpm astro check",
66
+ "pretest": "pnpm build",
67
+ "pretest:ci": "pnpm build",
68
+ "pretest:watch": "pnpm build:",
69
+ "pretest:update-snapshots": "pnpm build",
64
70
  "test": "node --experimental-test-snapshots --experimental-test-module-mocks --test 'test/**/*.test.ts'",
65
71
  "test:ci": "node --experimental-test-snapshots --experimental-test-module-mocks --experimental-test-coverage --test 'test/**/*.test.ts'",
66
72
  "test:watch": "node --experimental-test-snapshots --experimental-test-module-mocks --watch --test 'test/**/*.test.ts'",
@@ -29,7 +29,11 @@ const {
29
29
 
30
30
  <script>
31
31
  import type { WidgetAttributes, WidgetMethods } from "altcha/types";
32
- import type { AstroAltchaHydrationStrategy } from "astro-altcha/types";
32
+ import type {
33
+ AltchaWidgetTheme,
34
+ AstroAltchaHydrationStrategy,
35
+ } from "astro-altcha/types";
36
+ import { importTheme, mountWithStrategy } from "./common.ts";
33
37
 
34
38
  const templates = document.querySelectorAll("template");
35
39
 
@@ -49,19 +53,13 @@ for (const template of templates) {
49
53
  continue;
50
54
  }
51
55
 
52
- const mount = () => mountWidget(form, template, widget, debug === "true");
56
+ const theme = widget.getAttribute("theme") as AltchaWidgetTheme | null;
53
57
 
54
- switch (hydrationStrategy as AstroAltchaHydrationStrategy) {
55
- case "onload":
56
- void mount();
57
- break;
58
- case "onfocus":
59
- form.addEventListener("focusin", mount, { once: true });
60
- break;
61
- case "onhover":
62
- form.addEventListener("mouseenter", mount, { once: true });
63
- break;
64
- }
58
+ mountWithStrategy(
59
+ hydrationStrategy as AstroAltchaHydrationStrategy,
60
+ form,
61
+ () => mountWidget(form, template, widget, debug === "true", theme),
62
+ );
65
63
  }
66
64
 
67
65
  async function mountWidget(
@@ -69,13 +67,15 @@ async function mountWidget(
69
67
  template: HTMLTemplateElement,
70
68
  widget: HTMLElement & WidgetAttributes & WidgetMethods,
71
69
  debug: boolean,
70
+ theme?: AltchaWidgetTheme | null,
72
71
  ) {
73
- await import("altcha");
74
-
75
72
  const widgetReady = new Promise<void>((resolve) =>
76
73
  widget.addEventListener("load", () => resolve(), { once: true }),
77
74
  );
78
75
 
76
+ await importTheme(theme);
77
+ await import("altcha");
78
+
79
79
  template.replaceWith(widget);
80
80
  await widgetReady;
81
81
 
@@ -1,21 +1,94 @@
1
1
  ---
2
- import type { WidgetAttributes } from "altcha/types";
3
2
  import { obfuscate } from "altcha-lib/obfuscation";
3
+ import type { AltchaObfuscationProps } from "astro-altcha/types";
4
4
 
5
- type Props = {
6
- data: string;
7
- } & WidgetAttributes;
5
+ type Props = AltchaObfuscationProps;
8
6
 
9
- const { data, display = "floating" } = Astro.props;
7
+ const {
8
+ data,
9
+ hydrationStrategy = "onvisible",
10
+ debug,
11
+ display = "floating",
12
+ ...widgetProps
13
+ } = Astro.props;
10
14
  const dataObfuscated = await obfuscate(data);
11
15
  ---
12
16
 
13
- <altcha-widget data-obfuscated={dataObfuscated} {display}>
14
- <slot name="reveal">
17
+ <template
18
+ data-astro-altcha="obfuscation-widget"
19
+ data-hydration-strategy={hydrationStrategy}
20
+ data-debug={debug}
21
+ >
22
+ <altcha-widget
23
+ data-obfuscated={dataObfuscated}
24
+ {display}
25
+ {...widgetProps}
26
+ ></altcha-widget>
27
+ </template>
28
+ <span>
29
+ <slot>
15
30
  <button type="button">Click to reveal</button>
16
31
  </slot>
17
- </altcha-widget>
32
+ </span>
18
33
 
19
34
  <script>
20
- import "altcha/plugins/obfuscation";
35
+ import type { WidgetAttributes, WidgetMethods } from "altcha/lib";
36
+ import type {
37
+ AltchaWidgetTheme,
38
+ AstroAltchaHydrationStrategy,
39
+ } from "astro-altcha/types";
40
+ import { importTheme, mountWithStrategy } from "./common.ts";
41
+
42
+ const templates = document.querySelectorAll("template");
43
+
44
+ for (const template of templates) {
45
+ if (template.dataset.astroAltcha !== "obfuscation-widget") {
46
+ continue;
47
+ }
48
+
49
+ const trigger = template.nextElementSibling;
50
+ const widget = template.content.querySelector("altcha-widget");
51
+ if (!(trigger instanceof HTMLElement) || !widget) {
52
+ continue;
53
+ }
54
+
55
+ const { hydrationStrategy, debug } = template.dataset;
56
+ const theme = widget.getAttribute("theme") as AltchaWidgetTheme | null;
57
+
58
+ mountWithStrategy(
59
+ hydrationStrategy as AstroAltchaHydrationStrategy,
60
+ trigger,
61
+ () => mountWidget(template, trigger, widget, debug === "true", theme),
62
+ );
63
+ }
64
+
65
+ async function mountWidget(
66
+ template: HTMLTemplateElement,
67
+ trigger: Element,
68
+ widget: HTMLElement & WidgetAttributes & WidgetMethods,
69
+ debug: boolean,
70
+ theme?: AltchaWidgetTheme | null,
71
+ ) {
72
+ const widgetReady = new Promise<void>((resolve) =>
73
+ widget.addEventListener("load", () => resolve(), { once: true }),
74
+ );
75
+
76
+ await importTheme(theme);
77
+ await import("altcha/plugins/obfuscation");
78
+ await import("altcha");
79
+
80
+ // move the trigger into the widget
81
+ widget.append(trigger);
82
+ template.replaceWith(widget);
83
+
84
+ await widgetReady;
85
+
86
+ widget.configure({ debug });
87
+ widget.dispatchEvent(
88
+ new CustomEvent("astro-altcha:init", {
89
+ detail: { widget },
90
+ bubbles: true,
91
+ }),
92
+ );
93
+ }
21
94
  </script>
@@ -0,0 +1,63 @@
1
+ import type {
2
+ AltchaWidgetTheme,
3
+ AstroAltchaHydrationStrategy,
4
+ } from "astro-altcha/types";
5
+
6
+ export function mountWithStrategy(
7
+ strategy: AstroAltchaHydrationStrategy,
8
+ element: HTMLElement,
9
+ mount: () => void,
10
+ ) {
11
+ switch (strategy) {
12
+ case "onload":
13
+ void mount();
14
+ return;
15
+ case "onfocus":
16
+ element.addEventListener("focusin", mount, { once: true });
17
+ return;
18
+ case "onvisible": {
19
+ const observer = new IntersectionObserver(
20
+ (entries, observer) => {
21
+ if (!entries.some((entry) => entry.isIntersecting)) {
22
+ return;
23
+ }
24
+ observer.disconnect();
25
+ void mount();
26
+ },
27
+ { rootMargin: "200px 0px" },
28
+ );
29
+ observer.observe(element);
30
+ return;
31
+ }
32
+ }
33
+ }
34
+
35
+ export async function importTheme(theme?: AltchaWidgetTheme | null) {
36
+ if (!theme) {
37
+ return;
38
+ }
39
+
40
+ switch (theme) {
41
+ case "aqua":
42
+ await import("altcha/themes/aqua.min.css");
43
+ return;
44
+ case "business":
45
+ await import("altcha/themes/business.min.css");
46
+ return;
47
+ case "caramel":
48
+ await import("altcha/themes/caramel.min.css");
49
+ return;
50
+ case "cupcake":
51
+ await import("altcha/themes/cupcake.min.css");
52
+ return;
53
+ case "cyberpunk":
54
+ await import("altcha/themes/cyberpunk.min.css");
55
+ return;
56
+ case "lime":
57
+ await import("altcha/themes/lime.min.css");
58
+ return;
59
+ case "wireframe":
60
+ await import("altcha/themes/wireframe.min.css");
61
+ return;
62
+ }
63
+ }