@astralarium/repo-auth 0.1.0 → 0.2.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 +49 -0
- package/dist/client.d.ts +32 -16
- package/dist/client.js +42 -33
- package/dist/dev-sign-in/client.d.ts +21 -0
- package/dist/dev-sign-in/client.js +17 -0
- package/dist/dev-sign-in/endpoint.d.ts +2 -0
- package/dist/dev-sign-in/endpoint.js +2 -0
- package/dist/dev-sign-in/index.d.ts +4 -0
- package/dist/dev-sign-in/index.js +4 -0
- package/dist/dev-sign-in/provider-user.d.ts +30 -0
- package/dist/dev-sign-in/provider-user.js +55 -0
- package/dist/dev-sign-in/sign-in.d.ts +62 -0
- package/dist/dev-sign-in/sign-in.js +116 -0
- package/dist/index.server.d.ts +36 -11
- package/dist/index.server.js +4 -9
- package/dist/request.d.ts +2 -2
- package/dist/request.js +5 -3
- package/dist/start/index.d.ts +6 -0
- package/dist/start/index.js +6 -0
- package/dist/start/index.server.d.ts +28 -0
- package/dist/start/index.server.js +29 -0
- package/dist/start/middleware.d.ts +33 -0
- package/dist/start/middleware.js +70 -0
- package/dist/start/return-path.d.ts +16 -0
- package/dist/start/return-path.js +32 -0
- package/dist/start/viewer.d.ts +8 -0
- package/dist/start/viewer.js +15 -0
- package/dist/testkit/index.d.ts +18 -8
- package/dist/testkit/index.js +74 -43
- package/dist/testkit/suites.d.ts +52 -12
- package/dist/testkit/suites.js +114 -24
- package/package.json +19 -7
package/dist/request.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export declare const AUTH_BASE_PATH = "/api/auth";
|
|
|
9
9
|
export declare const AUTHORIZE_PATHS: ReadonlySet<string>;
|
|
10
10
|
/** Endpoints `app`'s client calls: session, social sign-in, each
|
|
11
11
|
* provider's callback and sign-out; account linking with `linkSocial`;
|
|
12
|
-
*
|
|
13
|
-
export declare function authPaths({ providers, linkSocial,
|
|
12
|
+
* dev sign-in's endpoints with `devSignIn`. */
|
|
13
|
+
export declare function authPaths({ providers, linkSocial, devSignIn, }: Pick<ShellAuthApp<AdditionalFields, SessionLike>, "providers" | "linkSocial" | "devSignIn">): ReadonlySet<string>;
|
|
14
14
|
/** A per-key limiter: Cloudflare's rate limiting binding. */
|
|
15
15
|
export interface RateLimiter {
|
|
16
16
|
limit(options: {
|
package/dist/request.js
CHANGED
|
@@ -4,13 +4,15 @@ export const AUTH_BASE_PATH = "/api/auth";
|
|
|
4
4
|
export const AUTHORIZE_PATHS = new Set(["/sign-in/social", "/link-social"].map(path => `${AUTH_BASE_PATH}${path}`));
|
|
5
5
|
/** Endpoints `app`'s client calls: session, social sign-in, each
|
|
6
6
|
* provider's callback and sign-out; account linking with `linkSocial`;
|
|
7
|
-
*
|
|
7
|
+
* dev sign-in's endpoints with `devSignIn`. */
|
|
8
8
|
export function authPaths({
|
|
9
9
|
providers,
|
|
10
10
|
linkSocial,
|
|
11
|
-
|
|
11
|
+
devSignIn
|
|
12
12
|
}) {
|
|
13
|
-
return new Set(["/get-session", "/sign-in/social", ...(linkSocial ? ["/link-social"] : []), ...(providers?.ids ?? []).map(id => `/callback/${id}`), "/sign-out", ...(
|
|
13
|
+
return new Set(["/get-session", "/sign-in/social", ...(linkSocial ? ["/link-social"] : []), ...(providers?.ids ?? []).map(id => `/callback/${id}`), "/sign-out", ...Object.values(devSignIn?.endpoints ?? {}).map(({
|
|
14
|
+
path
|
|
15
|
+
}) => path)].map(path => `${AUTH_BASE_PATH}${path}`));
|
|
14
16
|
}
|
|
15
17
|
/** Whether `request` carries a JSON object without `idToken`. ID-token
|
|
16
18
|
* sign-in skips state, PKCE and nonce; authorization takes the redirect
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** TanStack Start bindings of shell-auth, client-safe: the layout route
|
|
2
|
+
* context carrying the viewer's session, return paths, and server
|
|
3
|
+
* function middleware. */
|
|
4
|
+
export * from "./middleware.ts";
|
|
5
|
+
export * from "./return-path.ts";
|
|
6
|
+
export * from "./viewer.ts";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** TanStack Start bindings of shell-auth, client-safe: the layout route
|
|
2
|
+
* context carrying the viewer's session, return paths, and server
|
|
3
|
+
* function middleware. */
|
|
4
|
+
export * from "./middleware.js";
|
|
5
|
+
export * from "./return-path.js";
|
|
6
|
+
export * from "./viewer.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SessionLike } from "../types.ts";
|
|
2
|
+
/** Runs `fn` with the app's auth instance, its queries scoped to the
|
|
3
|
+
* request (`withShellAuth`). */
|
|
4
|
+
export type WithAuth<Auth> = <T>(fn: (auth: Auth) => Promise<T>) => Promise<T>;
|
|
5
|
+
/** What `viewerSession` reads of an auth instance: `shellAuth`'s
|
|
6
|
+
* projected `get-session`. */
|
|
7
|
+
export interface ViewerAuth {
|
|
8
|
+
api: {
|
|
9
|
+
getSession(context: {
|
|
10
|
+
headers: Headers;
|
|
11
|
+
returnHeaders: true;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
headers: Headers;
|
|
14
|
+
response: unknown;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
$Infer: {
|
|
18
|
+
Session: SessionLike;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Server function handler: the request's projected session, `null` signed
|
|
23
|
+
* out, read through `withAuth`; the response carries the read's cookies
|
|
24
|
+
* and is `private, no-store`. Apps declare the GET server function itself,
|
|
25
|
+
* as Start compiles top-level declarations alone:
|
|
26
|
+
* `createServerFn({ method: "GET" }).handler(viewerSession(withAuth))`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function viewerSession<Auth extends ViewerAuth>(withAuth: WithAuth<Auth>): () => Promise<Auth["$Infer"]["Session"] | null>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Server half of shell-auth's TanStack Start bindings: the viewer's
|
|
2
|
+
* session read. */
|
|
3
|
+
import { createServerOnlyFn } from "@tanstack/react-start";
|
|
4
|
+
import { appendResponseHeader, getRequestHeaders, setResponseHeader } from "@tanstack/react-start/server";
|
|
5
|
+
/** `auth`'s session of the request, `null` signed out. The response
|
|
6
|
+
* carries the read's `Set-Cookie` values (cookie cache refresh, session
|
|
7
|
+
* extension) and depends on the request, so shared caches skip it. */
|
|
8
|
+
const readViewerSession = createServerOnlyFn(async auth => {
|
|
9
|
+
setResponseHeader("cache-control", "private, no-store");
|
|
10
|
+
const {
|
|
11
|
+
headers,
|
|
12
|
+
response
|
|
13
|
+
} = await auth.api.getSession({
|
|
14
|
+
headers: getRequestHeaders(),
|
|
15
|
+
returnHeaders: true
|
|
16
|
+
});
|
|
17
|
+
for (const cookie of headers.getSetCookie()) appendResponseHeader("set-cookie", cookie);
|
|
18
|
+
return response;
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Server function handler: the request's projected session, `null` signed
|
|
22
|
+
* out, read through `withAuth`; the response carries the read's cookies
|
|
23
|
+
* and is `private, no-store`. Apps declare the GET server function itself,
|
|
24
|
+
* as Start compiles top-level declarations alone:
|
|
25
|
+
* `createServerFn({ method: "GET" }).handler(viewerSession(withAuth))`.
|
|
26
|
+
*/
|
|
27
|
+
export function viewerSession(withAuth) {
|
|
28
|
+
return () => withAuth(auth => readViewerSession(auth));
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Plain-text body of the 401 `Response` answering a signed-out call of
|
|
2
|
+
* an `authedMiddleware` server function. */
|
|
3
|
+
export declare const UNAUTHENTICATED = "unauthenticated";
|
|
4
|
+
/** Whether `error` is a 401 `Response`, as a signed-out call of an
|
|
5
|
+
* `authedMiddleware` server function rejects with. */
|
|
6
|
+
export declare function isUnauthenticated(error: unknown): error is Response;
|
|
7
|
+
/** Adds `sql`: one client over `connectionString()` per server function
|
|
8
|
+
* call; its one connection opens at the first query and closes when the
|
|
9
|
+
* handler resolves. The connection string reads per call: bindings are
|
|
10
|
+
* absent at static prerender. `connectionString` lands in the client
|
|
11
|
+
* build: a `createServerOnlyFn` drops its imports there. */
|
|
12
|
+
export declare function dbMiddleware(connectionString: () => string): import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, {
|
|
13
|
+
sql: import("postgres").Sql<{}>;
|
|
14
|
+
}, undefined, undefined, undefined>;
|
|
15
|
+
/** Adds the viewer's `session` from `readSession` (`viewerSession`'s
|
|
16
|
+
* server function), `null` signed out. The read opens its own
|
|
17
|
+
* connection (`withAuth`'s scope), closed before the handler runs; the
|
|
18
|
+
* cookie cache answers most reads with none. Handlers querying `sql`
|
|
19
|
+
* add `dbMiddleware` beside it. */
|
|
20
|
+
export declare function sessionMiddleware<Session>(readSession: () => Promise<Session | null>): import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, {
|
|
21
|
+
session: Awaited<Session> | null;
|
|
22
|
+
}, undefined, undefined, undefined>;
|
|
23
|
+
/** A `sessionMiddleware` over sessions of `Session`. */
|
|
24
|
+
export type SessionMiddleware<Session> = ReturnType<typeof sessionMiddleware<Session>>;
|
|
25
|
+
/** `session`'s context with a signed-in `session`, opening no
|
|
26
|
+
* connection of its own. A signed-out call throws a 401 `Response` with
|
|
27
|
+
* body `UNAUTHENTICATED`; Start rejects the caller's call with that
|
|
28
|
+
* `Response`, in the browser and during SSR (`isUnauthenticated`). */
|
|
29
|
+
export declare function authedMiddleware<Session>(session: SessionMiddleware<Session>): import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, readonly [import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, {
|
|
30
|
+
session: Awaited<Session> | null;
|
|
31
|
+
}, undefined, undefined, undefined>], undefined, {
|
|
32
|
+
session: NonNullable<Awaited<Session>>;
|
|
33
|
+
}, undefined, undefined, undefined>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Server function middleware of shell-auth: a database client per call,
|
|
2
|
+
* the viewer's session, and signed-in calls alone. Client-importable:
|
|
3
|
+
* this package lists `@tanstack/react-start` as a peer, so Start's Vite
|
|
4
|
+
* plugin compiles it, and the client compile strips `.server()` bodies
|
|
5
|
+
* and their imports. */
|
|
6
|
+
import { withSql } from "@astralarium/repo-postgres/sql";
|
|
7
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
8
|
+
/** Plain-text body of the 401 `Response` answering a signed-out call of
|
|
9
|
+
* an `authedMiddleware` server function. */
|
|
10
|
+
export const UNAUTHENTICATED = "unauthenticated";
|
|
11
|
+
/** Whether `error` is a 401 `Response`, as a signed-out call of an
|
|
12
|
+
* `authedMiddleware` server function rejects with. */
|
|
13
|
+
export function isUnauthenticated(error) {
|
|
14
|
+
return error instanceof Response && error.status === 401;
|
|
15
|
+
}
|
|
16
|
+
/** Adds `sql`: one client over `connectionString()` per server function
|
|
17
|
+
* call; its one connection opens at the first query and closes when the
|
|
18
|
+
* handler resolves. The connection string reads per call: bindings are
|
|
19
|
+
* absent at static prerender. `connectionString` lands in the client
|
|
20
|
+
* build: a `createServerOnlyFn` drops its imports there. */
|
|
21
|
+
export function dbMiddleware(connectionString) {
|
|
22
|
+
return createMiddleware({
|
|
23
|
+
type: "function"
|
|
24
|
+
}).server(({
|
|
25
|
+
next
|
|
26
|
+
}) => withSql(connectionString(), sql => next({
|
|
27
|
+
context: {
|
|
28
|
+
sql
|
|
29
|
+
}
|
|
30
|
+
})));
|
|
31
|
+
}
|
|
32
|
+
/** Adds the viewer's `session` from `readSession` (`viewerSession`'s
|
|
33
|
+
* server function), `null` signed out. The read opens its own
|
|
34
|
+
* connection (`withAuth`'s scope), closed before the handler runs; the
|
|
35
|
+
* cookie cache answers most reads with none. Handlers querying `sql`
|
|
36
|
+
* add `dbMiddleware` beside it. */
|
|
37
|
+
export function sessionMiddleware(readSession) {
|
|
38
|
+
return createMiddleware({
|
|
39
|
+
type: "function"
|
|
40
|
+
}).server(async ({
|
|
41
|
+
next
|
|
42
|
+
}) => next({
|
|
43
|
+
context: {
|
|
44
|
+
session: await readSession()
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
/** `session`'s context with a signed-in `session`, opening no
|
|
49
|
+
* connection of its own. A signed-out call throws a 401 `Response` with
|
|
50
|
+
* body `UNAUTHENTICATED`; Start rejects the caller's call with that
|
|
51
|
+
* `Response`, in the browser and during SSR (`isUnauthenticated`). */
|
|
52
|
+
export function authedMiddleware(session) {
|
|
53
|
+
return createMiddleware({
|
|
54
|
+
type: "function"
|
|
55
|
+
}).middleware([session]).server(({
|
|
56
|
+
next,
|
|
57
|
+
context
|
|
58
|
+
}) => {
|
|
59
|
+
if (!context.session)
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Start answers a thrown `Response` as is
|
|
61
|
+
throw new Response(UNAUTHENTICATED, {
|
|
62
|
+
status: 401
|
|
63
|
+
});
|
|
64
|
+
return next({
|
|
65
|
+
context: {
|
|
66
|
+
session: context.session
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Return paths: where a page sends the visitor once done, kept on this
|
|
2
|
+
* site. */
|
|
3
|
+
/** `url` resolved against `base`; null when it fails to parse. Stands in
|
|
4
|
+
* for `URL.parse`, which postdates the Vite browser baseline (Safari
|
|
5
|
+
* 16.4). */
|
|
6
|
+
export declare function parseUrl(url: string, base?: string): URL | null;
|
|
7
|
+
/** `url`'s normalized path, query and fragment when the browser resolves
|
|
8
|
+
* it as an origin-relative path; undefined when it or its normalized path
|
|
9
|
+
* (`//host`, from dot segments) names a host. */
|
|
10
|
+
export declare function ifUrlOkay(url?: string | null): string | undefined;
|
|
11
|
+
/** Search of a page returning to `r` once done; `validateSearch`. `r` is
|
|
12
|
+
* `ifUrlOkay`'s origin-relative path; an unsafe or non-string `r`
|
|
13
|
+
* drops. */
|
|
14
|
+
export declare function validateReturnSearch(search: Record<string, unknown>): {
|
|
15
|
+
r?: string;
|
|
16
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Return paths: where a page sends the visitor once done, kept on this
|
|
2
|
+
* site. */
|
|
3
|
+
/** `url` resolved against `base`; null when it fails to parse. Stands in
|
|
4
|
+
* for `URL.parse`, which postdates the Vite browser baseline (Safari
|
|
5
|
+
* 16.4). */
|
|
6
|
+
export function parseUrl(url, base) {
|
|
7
|
+
try {
|
|
8
|
+
return new URL(url, base);
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Stand-in origin for resolving paths. */
|
|
14
|
+
const PATH_BASE = "http://path.invalid";
|
|
15
|
+
/** `url`'s normalized path, query and fragment when the browser resolves
|
|
16
|
+
* it as an origin-relative path; undefined when it or its normalized path
|
|
17
|
+
* (`//host`, from dot segments) names a host. */
|
|
18
|
+
export function ifUrlOkay(url) {
|
|
19
|
+
if (!url?.startsWith("/")) return;
|
|
20
|
+
const resolved = parseUrl(url, PATH_BASE);
|
|
21
|
+
if (resolved?.origin !== PATH_BASE || resolved.pathname.startsWith("//")) return;
|
|
22
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
23
|
+
}
|
|
24
|
+
/** Search of a page returning to `r` once done; `validateSearch`. `r` is
|
|
25
|
+
* `ifUrlOkay`'s origin-relative path; an unsafe or non-string `r`
|
|
26
|
+
* drops. */
|
|
27
|
+
export function validateReturnSearch(search) {
|
|
28
|
+
const r = typeof search.r === "string" ? ifUrlOkay(search.r) : undefined;
|
|
29
|
+
return r === undefined ? {} : {
|
|
30
|
+
r
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ViewerContext } from "../client.ts";
|
|
2
|
+
/**
|
|
3
|
+
* `beforeLoad` of the layout route above every page reading the session:
|
|
4
|
+
* `{ session }` from `fetchViewer` (`viewerSession`'s server function) on
|
|
5
|
+
* the server, dehydrated with the match; `undefined` in the browser,
|
|
6
|
+
* whose live session store holds it (`watchViewerRouter`).
|
|
7
|
+
*/
|
|
8
|
+
export declare function viewerRouteContext<Session>(fetchViewer: () => Promise<Session | null>): () => Promise<ViewerContext<Session>>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The layout route context carrying the viewer's session in a TanStack
|
|
2
|
+
* Start app. */
|
|
3
|
+
import { createIsomorphicFn } from "@tanstack/react-start";
|
|
4
|
+
/**
|
|
5
|
+
* `beforeLoad` of the layout route above every page reading the session:
|
|
6
|
+
* `{ session }` from `fetchViewer` (`viewerSession`'s server function) on
|
|
7
|
+
* the server, dehydrated with the match; `undefined` in the browser,
|
|
8
|
+
* whose live session store holds it (`watchViewerRouter`).
|
|
9
|
+
*/
|
|
10
|
+
export function viewerRouteContext(fetchViewer) {
|
|
11
|
+
const read = createIsomorphicFn().server(() => fetchViewer());
|
|
12
|
+
return async () => ({
|
|
13
|
+
session: await read()
|
|
14
|
+
});
|
|
15
|
+
}
|
package/dist/testkit/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** Test support: a deployed `auth.*` schema checked against an app's auth
|
|
2
2
|
* instance, endpoint conformance (unserved paths, ID-token refusal,
|
|
3
3
|
* anonymous sessions), cookies out of auth responses, a rate-limit probe,
|
|
4
|
-
* social sign-in starts, session reads held on a lock, `
|
|
5
|
-
* rows inserted directly, and a fake OpenID
|
|
6
|
-
|
|
4
|
+
* social sign-in starts, session reads held on a lock, `testUtils`
|
|
5
|
+
* sessions, `auth.users` rows inserted directly, and a fake OpenID
|
|
6
|
+
* Connect provider. */
|
|
7
|
+
import { type Sql, type TransactionSql } from "@astralarium/repo-postgres/sql";
|
|
7
8
|
import { authPaths } from "../request.ts";
|
|
8
9
|
import { type JsonPost } from "./json-post.ts";
|
|
9
10
|
export * from "./oidc.ts";
|
|
@@ -108,13 +109,22 @@ export interface BlockedSessionReadOptions {
|
|
|
108
109
|
timeout?: number;
|
|
109
110
|
}
|
|
110
111
|
/**
|
|
111
|
-
* Runs `fn` while a
|
|
112
|
-
* and `readSession` (a database session read, e.g.
|
|
113
|
-
* session cookie and `disableCookieCache`) waits on
|
|
114
|
-
* releases as `fn` settles; rejects when no read reaches it
|
|
115
|
-
* `timeout`.
|
|
112
|
+
* Runs `fn` while a transaction over `db` holds `auth.session` exclusively
|
|
113
|
+
* (`holdTransaction`) and `readSession` (a database session read, e.g.
|
|
114
|
+
* `get-session` with a session cookie and `disableCookieCache`) waits on
|
|
115
|
+
* it. The lock releases as `fn` settles; rejects when no read reaches it
|
|
116
|
+
* within `timeout`.
|
|
116
117
|
*/
|
|
117
118
|
export declare function withBlockedSessionRead<R, T>(db: string, readSession: () => Promise<R>, fn: (blocked: BlockedSessionRead<R>) => T | Promise<T>, { applicationName, timeout }?: BlockedSessionReadOptions): Promise<T>;
|
|
119
|
+
/** What `userSession` reads of an auth instance: one with better-auth's
|
|
120
|
+
* `testUtils` plugin. */
|
|
121
|
+
interface TestUtilsAuth {
|
|
122
|
+
$context: Promise<object>;
|
|
123
|
+
}
|
|
124
|
+
/** A `cookie` header carrying a new session of user `userId`, signed in
|
|
125
|
+
* through `auth`'s `testUtils` login in a `withShellAuth` scope over
|
|
126
|
+
* `db`. */
|
|
127
|
+
export declare function userSession(db: string, auth: TestUtilsAuth, userId: string): Promise<string>;
|
|
118
128
|
/** Inserts an `auth.users` row: a fresh verified email, created now,
|
|
119
129
|
* `fields` adding or overriding columns (the app's `additionalFields`).
|
|
120
130
|
* Resolves to the new user's id. */
|
package/dist/testkit/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/** Test support: a deployed `auth.*` schema checked against an app's auth
|
|
2
|
+
* instance, endpoint conformance (unserved paths, ID-token refusal,
|
|
3
|
+
* anonymous sessions), cookies out of auth responses, a rate-limit probe,
|
|
4
|
+
* social sign-in starts, session reads held on a lock, `testUtils`
|
|
5
|
+
* sessions, `auth.users` rows inserted directly, and a fake OpenID
|
|
6
|
+
* Connect provider. */
|
|
7
|
+
import { sql as pooledSql } from "@astralarium/repo-postgres/sql";
|
|
8
|
+
import { holdTransaction } from "@astralarium/repo-postgres/testkit";
|
|
2
9
|
import { z } from "zod";
|
|
3
10
|
import { withShellAuth } from "../index.server.js";
|
|
4
11
|
import { AUTH_BASE_PATH, AUTHORIZE_PATHS, authPaths } from "../request.js";
|
|
@@ -134,59 +141,83 @@ export async function socialSignIn(request, {
|
|
|
134
141
|
return new URL(url);
|
|
135
142
|
}
|
|
136
143
|
/**
|
|
137
|
-
* Runs `fn` while a
|
|
138
|
-
* and `readSession` (a database session read, e.g.
|
|
139
|
-
* session cookie and `disableCookieCache`) waits on
|
|
140
|
-
* releases as `fn` settles; rejects when no read reaches it
|
|
141
|
-
* `timeout`.
|
|
144
|
+
* Runs `fn` while a transaction over `db` holds `auth.session` exclusively
|
|
145
|
+
* (`holdTransaction`) and `readSession` (a database session read, e.g.
|
|
146
|
+
* `get-session` with a session cookie and `disableCookieCache`) waits on
|
|
147
|
+
* it. The lock releases as `fn` settles; rejects when no read reaches it
|
|
148
|
+
* within `timeout`.
|
|
142
149
|
*/
|
|
143
150
|
export async function withBlockedSessionRead(db, readSession, fn, {
|
|
144
151
|
applicationName,
|
|
145
152
|
timeout = 10_000
|
|
146
153
|
} = {}) {
|
|
147
|
-
const
|
|
148
|
-
connectionString: db
|
|
149
|
-
});
|
|
150
|
-
await sql.connect();
|
|
154
|
+
const client = pooledSql(db);
|
|
151
155
|
try {
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
156
|
+
const held = await holdTransaction(hold => client.begin(async tx => {
|
|
157
|
+
await tx`LOCK TABLE auth.session IN ACCESS EXCLUSIVE MODE`;
|
|
158
|
+
await hold(tx);
|
|
159
|
+
}));
|
|
160
|
+
try {
|
|
161
|
+
const response = readSession();
|
|
162
|
+
// Rejections surface through `fn`; a timeout leaves them unobserved.
|
|
163
|
+
response.catch(() => undefined);
|
|
164
|
+
const deadline = Date.now() + timeout;
|
|
165
|
+
for (;;) {
|
|
166
|
+
// pg_locks and pg_stat_activity.usename are visible across roles.
|
|
167
|
+
const [row] = await client`
|
|
168
|
+
SELECT a.pid, a.usename, r.rolsuper
|
|
169
|
+
FROM pg_locks l
|
|
170
|
+
JOIN pg_stat_activity a ON a.pid = l.pid
|
|
171
|
+
JOIN pg_roles r ON r.rolname = a.usename
|
|
172
|
+
WHERE l.relation = 'auth.session'::regclass AND NOT l.granted
|
|
173
|
+
AND l.mode = 'AccessShareLock'
|
|
174
|
+
AND ${held.holder}::integer = ANY(pg_blocking_pids(l.pid))
|
|
175
|
+
AND (${applicationName ?? null}::text IS NULL
|
|
176
|
+
OR a.application_name = ${applicationName ?? null})
|
|
177
|
+
`;
|
|
178
|
+
if (row) {
|
|
179
|
+
return await fn({
|
|
180
|
+
pid: row.pid,
|
|
181
|
+
role: row.usename,
|
|
182
|
+
superuser: row.rolsuper,
|
|
183
|
+
response
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (Date.now() > deadline) {
|
|
187
|
+
throw new Error(`no session read waited on auth.session in ${timeout} ms`);
|
|
188
|
+
}
|
|
189
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
183
190
|
}
|
|
184
|
-
|
|
191
|
+
} finally {
|
|
192
|
+
held.release();
|
|
193
|
+
await held.done;
|
|
185
194
|
}
|
|
186
195
|
} finally {
|
|
187
|
-
await
|
|
196
|
+
await client.end();
|
|
188
197
|
}
|
|
189
198
|
}
|
|
199
|
+
/** Whether `context` carries `testUtils`' helpers. */
|
|
200
|
+
function hasTestHelpers(context) {
|
|
201
|
+
return "test" in context;
|
|
202
|
+
}
|
|
203
|
+
/** A `cookie` header carrying a new session of user `userId`, signed in
|
|
204
|
+
* through `auth`'s `testUtils` login in a `withShellAuth` scope over
|
|
205
|
+
* `db`. */
|
|
206
|
+
export function userSession(db, auth, userId) {
|
|
207
|
+
return withShellAuth(db, async () => {
|
|
208
|
+
const context = await auth.$context;
|
|
209
|
+
if (!hasTestHelpers(context)) throw new Error("userSession: the auth instance lacks testUtils");
|
|
210
|
+
const {
|
|
211
|
+
cookies
|
|
212
|
+
} = await context.test.login({
|
|
213
|
+
userId
|
|
214
|
+
});
|
|
215
|
+
return cookies.map(({
|
|
216
|
+
name,
|
|
217
|
+
value
|
|
218
|
+
}) => `${name}=${value}`).join("; ");
|
|
219
|
+
});
|
|
220
|
+
}
|
|
190
221
|
/** Inserts an `auth.users` row: a fresh verified email, created now,
|
|
191
222
|
* `fields` adding or overriding columns (the app's `additionalFields`).
|
|
192
223
|
* Resolves to the new user's id. */
|
package/dist/testkit/suites.d.ts
CHANGED
|
@@ -26,16 +26,55 @@ export interface SessionSource {
|
|
|
26
26
|
start(): Promise<string>;
|
|
27
27
|
end(): Promise<void>;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
export declare function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
/** Dev sign-in through the app at `origin` as `accountId`
|
|
30
|
+
* (`ShellAuthApp.devSignIn`): its answer. */
|
|
31
|
+
export declare function devSignIn(request: AppRequester, origin: string, accountId: string): Promise<{
|
|
32
|
+
status: number;
|
|
33
|
+
headers: {
|
|
34
|
+
get(name: string): string | null;
|
|
35
|
+
getSetCookie(): string[];
|
|
36
|
+
};
|
|
37
|
+
text(): Promise<string>;
|
|
38
|
+
body: {
|
|
39
|
+
cancel(): Promise<void>;
|
|
40
|
+
} | null;
|
|
41
|
+
}>;
|
|
42
|
+
/** A session `devSignInAs` signed in. */
|
|
43
|
+
export interface DevSignedIn {
|
|
44
|
+
/** The session's user. */
|
|
45
|
+
userId: string;
|
|
46
|
+
/** `cookie` header of the response's cookies. */
|
|
47
|
+
cookie: string;
|
|
48
|
+
/** The response's `Set-Cookie` values. */
|
|
49
|
+
setCookies: string[];
|
|
50
|
+
}
|
|
51
|
+
/** Signs in through the app's dev sign-in at `origin` as `accountId`;
|
|
52
|
+
* rejects naming any answer other than 200. */
|
|
53
|
+
export declare function devSignInAs(request: AppRequester, origin: string, accountId: string): Promise<DevSignedIn>;
|
|
54
|
+
/** The session and user ids `cookie` carries, read past the cookie
|
|
55
|
+
* cache. */
|
|
56
|
+
export declare function sessionOf(request: AppRequester, cookie: string): Promise<{
|
|
57
|
+
session: {
|
|
58
|
+
id: string;
|
|
59
|
+
};
|
|
60
|
+
user: {
|
|
61
|
+
id: string;
|
|
62
|
+
};
|
|
63
|
+
}>;
|
|
64
|
+
/** A session signed in through the app's dev sign-in at `origin` as a
|
|
65
|
+
* fresh `devAccountId()` (apps with `devSignIn`); its new user is deleted
|
|
66
|
+
* from `db` at `end`. */
|
|
67
|
+
export declare function devSignInSession(request: AppRequester, { origin, db, devAccountId, }: Pick<DevAuthSuite, "origin" | "db" | "devAccountId">): SessionSource;
|
|
35
68
|
/** What `testDevAuth` runs against. */
|
|
36
69
|
export interface DevAuthSuite {
|
|
37
70
|
/** The signed-in visitor. */
|
|
38
71
|
session: SessionSource;
|
|
72
|
+
/** The app's origin; dev sign-in posts from it. */
|
|
73
|
+
origin: string;
|
|
74
|
+
/** A fresh account id of the app's `devSignIn` provider: unheld by any
|
|
75
|
+
* account, its `newUser` email by any user. The suite deletes each user
|
|
76
|
+
* dev sign-in answers for one. */
|
|
77
|
+
devAccountId: () => string;
|
|
39
78
|
/** Connection string of the app's database. */
|
|
40
79
|
db: string;
|
|
41
80
|
/** Postgres role the app queries as. */
|
|
@@ -55,11 +94,12 @@ export interface DevAuthSuite {
|
|
|
55
94
|
}
|
|
56
95
|
/**
|
|
57
96
|
* Tests of `request`'s dev app serving auth over its Postgres: the database
|
|
58
|
-
* role, public pages independent of the viewer,
|
|
59
|
-
* each local origin, and the rate limit, last: it exhausts
|
|
60
|
-
* budget for the window.
|
|
97
|
+
* role, public pages independent of the viewer, dev sign-in, provider
|
|
98
|
+
* callbacks on each local origin, and the rate limit, last: it exhausts
|
|
99
|
+
* the client's budget for the window. Users dev sign-in creates are
|
|
100
|
+
* deleted from `db` after.
|
|
61
101
|
*/
|
|
62
|
-
export declare function testDevAuth(request: AppRequester, { session, db, role, publicPages, provider, localOrigins, rateLimit, }: DevAuthSuite): void;
|
|
102
|
+
export declare function testDevAuth(request: AppRequester, { session, origin, devAccountId, db, role, publicPages, provider, localOrigins, rateLimit, }: DevAuthSuite): void;
|
|
63
103
|
/** What `testProductionAuth` runs against. */
|
|
64
104
|
export interface ProductionAuthSuite<Fields extends AdditionalFields, Projected extends SessionLike> {
|
|
65
105
|
/** The app's public origin. */
|
|
@@ -78,8 +118,8 @@ export interface ProductionAuthSuite<Fields extends AdditionalFields, Projected
|
|
|
78
118
|
}
|
|
79
119
|
/**
|
|
80
120
|
* Tests of `request`'s production app serving auth, against `app` built for
|
|
81
|
-
* production (`
|
|
82
|
-
* endpoints, ID-token refusal, the anonymous session, a failed provider
|
|
121
|
+
* production (`devSignIn` off): its schema, 404s outside the served
|
|
122
|
+
* endpoints and at dev sign-in, ID-token refusal, the anonymous session, a failed provider
|
|
83
123
|
* callback landing on the error page, and each provider's sign-in with its
|
|
84
124
|
* runtime client and callback.
|
|
85
125
|
*/
|