@gjermundgaraba/clankerauth-dev 0.5.1 → 0.6.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/README.md +59 -1
- package/dist/THIRD_PARTY_NOTICES.txt +1 -1
- package/dist/edge.d.ts +84 -0
- package/dist/edge.mjs +2 -0
- package/dist/index.d.ts +43 -2
- package/dist/index.mjs +34 -34
- package/dist/web/assets/{index-BtTQ7_kn.js → index-TYt-q9U4.js} +14 -14
- package/dist/web/index.html +1 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -30,6 +30,33 @@ await issuer.close();
|
|
|
30
30
|
|
|
31
31
|
Each call listens on a random loopback port, creates a fresh SQLite database and secret in a temporary directory, provisions the owner, the resources and the client in-process through the server's administration module, and returns. Only the returned issuer is reached over HTTP. The client is first party: once the owner is signed in, an authorization request redirects straight to the callback with a code and no consent step. `close()` drains in-flight requests, closes the database and deletes the directory. Nothing is shared between calls and nothing survives them.
|
|
32
32
|
|
|
33
|
+
## Provisioning
|
|
34
|
+
|
|
35
|
+
The issuer provisions what a test or a development script would otherwise script by hand against its HTTP API:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const key = await issuer.apiKey({ permissions: { [resource.identifier]: ["notes:read"] } });
|
|
39
|
+
|
|
40
|
+
// Needs a cookieDomain: the forward cookie is what a browser holds.
|
|
41
|
+
const { cookie, cookies } = await issuer.ownerSession("http://app.notes.localhost:5173");
|
|
42
|
+
const token = await issuer.ownerToken({
|
|
43
|
+
resource: resource.identifier,
|
|
44
|
+
appOrigin: "http://app.notes.localhost:5173",
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`apiKey` returns the secret once. `ownerSession` signs the owner in and seals the forward cookie exactly as `/forward-auth/continue` does for a browser; `cookies` is the name/value form a browser-automation context takes. `ownerToken` runs the same forward-auth check and returns the access token, so a test never assembles a cookie jar.
|
|
49
|
+
|
|
50
|
+
## A workspace that survives a restart
|
|
51
|
+
|
|
52
|
+
Pass `dataDir` and a fixed `port` to keep the issuer's state:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const issuer = await startDisposableIssuer({ ...options, dataDir: ".dev/auth", port: 5174 });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The owner account and password, the signing secret, the registered client and the database are kept in that directory, and provisioning is idempotent, so the next start reuses them: the same sign-in works, API keys minted before still verify, and browser storage keyed by the origin survives. A fixed port is part of that, because the origin is what cookies and registered redirect URIs are keyed by. `close()` leaves the directory alone. Treat it as the whole secret of the workspace and keep it out of version control. Without `dataDir`, nothing changes: a temporary directory, a random port, and everything removed by `close()`.
|
|
59
|
+
|
|
33
60
|
To develop an app behind forward auth, pass a `cookieDomain`:
|
|
34
61
|
|
|
35
62
|
```ts
|
|
@@ -42,7 +69,38 @@ const issuer = await startDisposableIssuer({
|
|
|
42
69
|
issuer.url; // http://auth.notes.localhost:<port>
|
|
43
70
|
```
|
|
44
71
|
|
|
45
|
-
The issuer then serves `/forward-auth?resource=<identifier>`, `/forward-auth/continue` and `/forward-auth/logout`, and shares its forward cookie with every host under the domain, so serve the app on one, such as `app.notes.localhost`. Browsers and Node resolve `.localhost` names to loopback without a hosts-file entry. A signed-in owner's check answers `204` with an `Authorization` header to copy upstream; without a session, a page navigation (`Sec-Fetch-Mode: navigate`) is redirected through sign-in and any other request is `401`.
|
|
72
|
+
The issuer then serves `/forward-auth?resource=<identifier>`, `/forward-auth/continue` and `/forward-auth/logout`, and shares its forward cookie with every host under the domain, so serve the app on one, such as `app.notes.localhost`. Browsers and Node resolve `.localhost` names to loopback without a hosts-file entry. A signed-in owner's check answers `204` with an `Authorization` header to copy upstream; without a session, a page navigation (`Sec-Fetch-Mode: navigate`) is redirected through sign-in and any other request is `401`. Without `cookieDomain` the issuer is `http://127.0.0.1:<port>` and these routes are not served.
|
|
73
|
+
|
|
74
|
+
## The edge
|
|
75
|
+
|
|
76
|
+
`@gjermundgaraba/clankerauth-dev/edge` is the deployment edge for local development: the `forward_auth` check, and for upgrades a reverse proxy. Do not write one again.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { forwardAuth, reserveLoopbackPort } from "@gjermundgaraba/clankerauth-dev/edge";
|
|
80
|
+
|
|
81
|
+
export default defineConfig({
|
|
82
|
+
plugins: [
|
|
83
|
+
forwardAuth({
|
|
84
|
+
issuer: "http://auth.notes.localhost:5174",
|
|
85
|
+
appOrigin: "http://app.notes.localhost:5173",
|
|
86
|
+
resource: "http://app.notes.localhost:5173/",
|
|
87
|
+
backend: "http://127.0.0.1:8080",
|
|
88
|
+
socketPaths: ["/eventlog"],
|
|
89
|
+
onError: (error) => console.error("[forward-auth]", error.message),
|
|
90
|
+
}),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Nothing in this package imports Vite, at run time or in its types: the plugin is the plain object Vite accepts. On a plain Node server, `attach(server, options)` installs the same two halves and returns the request middleware; `check(options)` and `middleware(options)` are available on their own.
|
|
96
|
+
|
|
97
|
+
- The check uses `node:http`, never `fetch`: `fetch` owns `Sec-Fetch-Mode`, and that header is what tells the issuer whether a request may be redirected through sign-in. The browser's own header is relayed, along with its cookie, plus `x-forwarded-proto`, `-host` and `-uri`.
|
|
98
|
+
- It has a timeout (5 s by default) and **rejects** rather than deciding when the issuer is unreachable, too slow, or answers `204` with no `Authorization` to forward. `middleware` turns a rejection into a 502 and passes the error to `onError`; an empty credential is never forwarded upstream.
|
|
99
|
+
- `publicPaths` defaults to `/mcp`, `/.well-known` and `/healthz`, and a request that already carries an `Authorization` header is never checked: agents onboard themselves and probes run.
|
|
100
|
+
- `upgrade` only takes the paths in `socketPaths`, so a development server keeps its own hot-reload socket; it returns `false` for everything else, which on a plain Node server means another listener must answer them. A checked upgrade is proxied to `backend`; a half closing normally ends the other half, and only an error destroys. A socket error is reported through `onError` and never crashes the server.
|
|
101
|
+
- `reserveLoopbackPort()` claims a free port before anything that must know its own origin up front.
|
|
102
|
+
|
|
103
|
+
The edge's types import `node:http` types, so a TypeScript consumer needs `@types/node`.
|
|
46
104
|
|
|
47
105
|
Two optional test hooks help exercise integrations:
|
|
48
106
|
|
package/dist/edge.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
export interface CheckOptions {
|
|
4
|
+
/** The issuer origin serving `/forward-auth`, under the apps' shared cookie domain. */
|
|
5
|
+
readonly issuer: string;
|
|
6
|
+
/** The origin browsers reach this app at. Its host and scheme are forwarded. */
|
|
7
|
+
readonly appOrigin: string;
|
|
8
|
+
/**
|
|
9
|
+
* The resource identifier to ask for. One resource per app is a string; an app whose
|
|
10
|
+
* surfaces are separate resources passes a function of the request path.
|
|
11
|
+
*/
|
|
12
|
+
readonly resource: string | ((path: string) => string);
|
|
13
|
+
/** How long to wait for the issuer. Default 5000 ms. */
|
|
14
|
+
readonly timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
/** What the issuer decided about one request. */
|
|
17
|
+
export interface Decision {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly authorization: string | undefined;
|
|
20
|
+
readonly location: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
/** The request fields a check reads. A Node `IncomingMessage` already is one. */
|
|
23
|
+
export interface CheckedRequest {
|
|
24
|
+
readonly url?: string | undefined;
|
|
25
|
+
readonly headers: IncomingHttpHeaders;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ask the issuer whether one request may proceed. Rejects instead of deciding when the
|
|
29
|
+
* issuer is unreachable, too slow, or answers `204` without the credential to forward:
|
|
30
|
+
* those are a broken edge, not a refusal to show a browser.
|
|
31
|
+
*/
|
|
32
|
+
export declare const check: (options: CheckOptions) => (request: CheckedRequest) => Promise<Decision>;
|
|
33
|
+
export interface EdgeOptions extends CheckOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Path prefixes answered without a check, because their callers bring their own
|
|
36
|
+
* credential or must work without one. Defaults to `/mcp`, `/.well-known`, `/healthz`.
|
|
37
|
+
*/
|
|
38
|
+
readonly publicPaths?: readonly string[];
|
|
39
|
+
/** Where a checked upgrade is proxied, such as `http://127.0.0.1:8080`. */
|
|
40
|
+
readonly backend?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Paths whose upgrades this edge owns. Everything else, including a development
|
|
43
|
+
* server's own hot-reload socket, is left untouched. Defaults to none.
|
|
44
|
+
*/
|
|
45
|
+
readonly socketPaths?: readonly string[];
|
|
46
|
+
/** Called with anything this edge swallowed, so a dev server can print it. */
|
|
47
|
+
readonly onError?: (error: Error) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Connect-style middleware: an unauthenticated navigation is redirected through the
|
|
51
|
+
* issuer and back, anything else is refused in place, and a request that already
|
|
52
|
+
* carries a credential passes straight through.
|
|
53
|
+
*/
|
|
54
|
+
export declare const middleware: (options: EdgeOptions) => (request: CheckedRequest, response: ServerResponse, next: () => void) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Handle one `upgrade`. Returns `false` for a path this edge does not own, so a
|
|
57
|
+
* development server keeps its own hot-reload socket. An upgrade is never a
|
|
58
|
+
* navigation, so an unauthenticated one is refused in place: the page signs in and
|
|
59
|
+
* the client reconnects.
|
|
60
|
+
*/
|
|
61
|
+
export declare const upgrade: (options: EdgeOptions) => (request: IncomingMessage, socket: Duplex, head: Buffer) => boolean;
|
|
62
|
+
/** Install both halves on a plain Node server, for an edge that is not a Vite plugin. */
|
|
63
|
+
export declare const attach: (server: Server, options: EdgeOptions) => (request: CheckedRequest, response: ServerResponse, next: () => void) => void;
|
|
64
|
+
/** The development server this plugin attaches to. Structural, so Vite is never imported. */
|
|
65
|
+
export interface DevServerLike {
|
|
66
|
+
readonly middlewares: {
|
|
67
|
+
use(handler: (request: IncomingMessage, response: ServerResponse, next: () => void) => void): void;
|
|
68
|
+
};
|
|
69
|
+
readonly httpServer?: {
|
|
70
|
+
on(event: "upgrade", listener: (request: IncomingMessage, socket: Duplex, head: Buffer) => void): void;
|
|
71
|
+
} | null | undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A Vite plugin applying the same edge. Nothing here imports Vite: the object is
|
|
75
|
+
* structurally what Vite accepts, so this package never puts Vite in a consumer's
|
|
76
|
+
* dependency graph or its type graph.
|
|
77
|
+
*/
|
|
78
|
+
export declare const forwardAuth: (options: EdgeOptions) => {
|
|
79
|
+
name: string;
|
|
80
|
+
apply: "serve";
|
|
81
|
+
configureServer: (server: DevServerLike) => void;
|
|
82
|
+
};
|
|
83
|
+
/** Claim a free loopback port before anything that must know its own origin up front. */
|
|
84
|
+
export declare const reserveLoopbackPort: () => Promise<number>;
|
package/dist/edge.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createServer as e,request as t}from"node:http";const n=[`/mcp`,`/.well-known`,`/healthz`],r=e=>typeof e==`function`,i=e=>{let t=e.search(/[?#]/u);return t===-1?e:e.slice(0,t)},a=e=>e===void 0?``:Array.isArray(e)?e.join(`, `):String(e),o=e=>{let n=new URL(e.appOrigin),o=e.timeoutMs??5e3,s=t=>r(e.resource)?e.resource(t):e.resource;return r=>new Promise((c,l)=>{let u=r.url??`/`,d=new URL(`/forward-auth`,e.issuer);d.searchParams.set(`resource`,s(i(u)));let f=t(d,{headers:{cookie:a(r.headers.cookie),"sec-fetch-mode":a(r.headers[`sec-fetch-mode`]),"x-forwarded-proto":n.protocol.slice(0,-1),"x-forwarded-host":n.host,"x-forwarded-uri":u}},e=>{e.resume();let t=e.statusCode??502,{authorization:n,location:r}=e.headers;if(t===204&&n===void 0){l(/* @__PURE__ */ Error(`Forward auth answered 204 without an Authorization header to forward`));return}c({status:t,authorization:n,location:r})});f.setTimeout(o,()=>{f.destroy(/* @__PURE__ */ Error(`Forward auth did not answer within ${o}ms`))}),f.on(`error`,l),f.end()})},s=(e,t)=>t.some(t=>e===t||e.startsWith(`${t}/`)),c=e=>e instanceof Error?e:Error(String(e)),l=e=>{let t=o(e),r=e.publicPaths??[...n];return(n,a,o)=>{let l=i(n.url??`/`);if(n.headers.authorization!==void 0||s(l,r)){o();return}t(n).then(e=>{if(e.status===204){n.headers.authorization=e.authorization,o();return}a.writeHead(e.status,e.location===void 0?{}:{location:e.location}),a.end()},t=>{let n=c(t);e.onError?.(n),a.writeHead(502,{"content-type":`text/plain`}),a.end(n.message)})}},u=e=>Object.fromEntries(Object.entries(e).flatMap(([e,t])=>t===void 0?[]:[[e,Array.isArray(t)?t.join(`, `):t]])),d=e=>{let n=o(e);return(r,a,o)=>{let l=i(r.url??`/`);if(e.backend===void 0||!s(l,e.socketPaths??[]))return!1;let d=new URL(e.backend),f=(t,n,r)=>{r&&e.onError?.(r),a.end(`HTTP/1.1 ${t} ${n}\r\nConnection: close\r\n\r\n`)};a.on(`error`,t=>{e.onError?.(t),a.destroy()});let p=n=>{let i=t({hostname:d.hostname,port:d.port,path:r.url??`/`,headers:{...u(r.headers),authorization:n}});i.on(`upgrade`,(t,n,r)=>{let i=`HTTP/1.1 101 Switching Protocols\r
|
|
2
|
+
`;for(let e=0;e<t.rawHeaders.length;e+=2)i+=`${t.rawHeaders[e]}: ${t.rawHeaders[e+1]}\r\n`;a.write(`${i}\r\n`),r.length&&a.write(r),o.length&&n.write(o),n.on(`error`,t=>{e.onError?.(t),a.destroy()}),a.once(`close`,()=>n.end()),n.once(`close`,()=>a.end()),a.pipe(n),n.pipe(a)}),i.on(`response`,e=>{e.resume(),f(e.statusCode??502,`Upgrade refused`)}),i.on(`error`,e=>f(502,`Bad Gateway`,e)),i.end()};return n(r).then(e=>{if(e.status!==204||e.authorization===void 0){f(401,`Unauthorized`);return}p(e.authorization)},e=>f(502,`Bad Gateway`,c(e))),!0}},f=(e,t)=>{let n=l(t),r=d(t);return e.on(`upgrade`,(e,t,n)=>{r(e,t,n)}),n},p=e=>{let t=l(e),n=d(e);return{name:`clankerauth-forward-auth`,apply:`serve`,configureServer:e=>{e.middlewares.use(t),e.httpServer?.on(`upgrade`,(e,t,r)=>{n(e,t,r)})}}},m=async()=>{let t=e();await new Promise(e=>t.listen(0,`127.0.0.1`,e));let n=t.address();if(!(n instanceof Object))throw Error(`Could not reserve a loopback port`);return await new Promise((e,n)=>t.close(t=>t?n(t):e())),n.port};export{f as attach,o as check,p as forwardAuth,l as middleware,m as reserveLoopbackPort,d as upgrade};
|
package/dist/index.d.ts
CHANGED
|
@@ -8,22 +8,63 @@ export interface DisposableIssuerOptions {
|
|
|
8
8
|
* `http://127.0.0.1:<port>` and the forward-auth routes are not served.
|
|
9
9
|
*/
|
|
10
10
|
cookieDomain?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Keep the issuer's state in this directory instead of a temporary one: the owner
|
|
13
|
+
* account and password, the signing secret, the registered client and the database
|
|
14
|
+
* survive `close()` and the next start. Provisioning is idempotent, so the same
|
|
15
|
+
* resources and client are reused. The directory is never deleted. Treat it as the
|
|
16
|
+
* whole secret of the workspace, and keep it out of version control.
|
|
17
|
+
*/
|
|
18
|
+
dataDir?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Listen on this port instead of a random one. A persistent workspace needs a fixed
|
|
21
|
+
* one, because the origin is what browser storage, cookies and registered redirect
|
|
22
|
+
* URIs are keyed by. Default `0`.
|
|
23
|
+
*/
|
|
24
|
+
port?: number;
|
|
11
25
|
/** Test hook replacing outbound CIMD metadata retrieval. Defaults to the secure transport. */
|
|
12
26
|
cimdTransport?: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
|
|
13
27
|
/** Test hook observing incoming HTTP requests, including provisioning. No headers or bodies. */
|
|
14
28
|
onRequest?: (request: { method: string; url: URL }) => void;
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/** What a persistent workspace keeps between runs. Written by this package only. */
|
|
32
|
+
export interface Identity {
|
|
33
|
+
email: string;
|
|
34
|
+
password: string;
|
|
35
|
+
secret: string;
|
|
36
|
+
clientId?: string;
|
|
37
|
+
clientSecret?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A browser-equivalent owner session: the forward cookie, as a header and as pairs. */
|
|
41
|
+
export interface OwnerSession {
|
|
42
|
+
cookie: string;
|
|
43
|
+
cookies: Array<{ name: string; value: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export interface DisposableIssuer {
|
|
18
47
|
issuer: string;
|
|
19
48
|
clientId: string;
|
|
20
49
|
clientSecret: string;
|
|
21
50
|
owner: { email: string; password: string };
|
|
22
51
|
url: string;
|
|
52
|
+
/** The port the issuer listens on, which is also the apps' port under a cookie domain. */
|
|
53
|
+
port: number;
|
|
23
54
|
directory: string;
|
|
24
|
-
/**
|
|
55
|
+
/** Mint a scoped API key and return its secret. Permissions are resource identifier to scopes. */
|
|
56
|
+
apiKey(options: { name?: string; permissions: Record<string, string[]> }): Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Sign the owner in and seal the forward cookie for `appOrigin`, exactly as a browser
|
|
59
|
+
* does through `/forward-auth/continue`. Needs `cookieDomain`. The `cookies` array is
|
|
60
|
+
* what a browser automation harness adds to a context.
|
|
61
|
+
*/
|
|
62
|
+
ownerSession(appOrigin: string): Promise<OwnerSession>;
|
|
63
|
+
/** An access token for one resource, obtained through the same forward-auth check. */
|
|
64
|
+
ownerToken(options: { resource: string; appOrigin: string }): Promise<string>;
|
|
65
|
+
/** Drain requests and close SQLite. Removes the directory only when it is temporary. Idempotent. */
|
|
25
66
|
close(): Promise<void>;
|
|
26
67
|
}
|
|
27
68
|
|
|
28
|
-
/** Start
|
|
69
|
+
/** Start an issuer on a loopback port. The caller owns signals and must await close(). */
|
|
29
70
|
export function startDisposableIssuer(options: DisposableIssuerOptions): Promise<DisposableIssuer>;
|