@astralarium/repo-auth 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +59 -2
- package/dist/client.d.ts +2173 -0
- package/dist/client.js +203 -0
- 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 +645 -0
- package/dist/index.server.js +307 -0
- package/dist/providers/google.d.ts +8 -0
- package/dist/providers/google.js +11 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.js +3 -0
- package/dist/providers/provider.d.ts +135 -0
- package/dist/providers/provider.js +86 -0
- package/dist/request.d.ts +42 -0
- package/dist/request.js +75 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +23 -0
- 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 +131 -0
- package/dist/testkit/index.js +238 -0
- package/dist/testkit/json-post.d.ts +8 -0
- package/dist/testkit/json-post.js +12 -0
- package/dist/testkit/oidc.d.ts +35 -0
- package/dist/testkit/oidc.js +154 -0
- package/dist/testkit/suites.d.ts +126 -0
- package/dist/testkit/suites.js +255 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +1 -0
- package/package.json +86 -3
- package/sql/jobs/schema.sql +19 -0
- package/sql/schema.sql +97 -0
- package/sql/scrub.sql +6 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** An OpenID Connect provider as a `fetch` stand-in: discovery, JWKS, a
|
|
2
|
+
* token endpoint issuing RS256 ID tokens for the codes `authorize`
|
|
3
|
+
* grants, and userinfo. */
|
|
4
|
+
/** `fakeOidcProvider` setup. */
|
|
5
|
+
export interface FakeOidcProviderOptions {
|
|
6
|
+
/** Issuer URL; discovery at `<issuer>/.well-known/openid-configuration`. */
|
|
7
|
+
issuer: string;
|
|
8
|
+
/** The relying party's client id: the ID token audience. */
|
|
9
|
+
clientId: string;
|
|
10
|
+
/** ID token claims of every account, beside its own. */
|
|
11
|
+
claims?: Record<string, unknown>;
|
|
12
|
+
/** Space-separated grant of every account; empty omits it. Default
|
|
13
|
+
* `openid`. */
|
|
14
|
+
scope?: string;
|
|
15
|
+
}
|
|
16
|
+
/** An account approving an authorization. */
|
|
17
|
+
export interface FakeOidcAccount {
|
|
18
|
+
/** Account id: the ID token `sub`. */
|
|
19
|
+
sub: string;
|
|
20
|
+
/** ID token and userinfo claims beside `sub`, over the provider's. */
|
|
21
|
+
claims?: Record<string, unknown>;
|
|
22
|
+
/** Space-separated grant; empty omits it. Default the provider's. */
|
|
23
|
+
scope?: string;
|
|
24
|
+
/** ID token `nonce`. Default the authorization request's. */
|
|
25
|
+
nonce?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The provider for client `clientId` at `issuer`. `fetch` serves its
|
|
29
|
+
* endpoints, 404 elsewhere; PKCE (S256) and the redirect URI bind each
|
|
30
|
+
* code, used once. `authorize` approves an authorization URL.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fakeOidcProvider({ issuer, clientId, claims: providerClaims, scope: providerScope, }: FakeOidcProviderOptions): Promise<{
|
|
33
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
34
|
+
authorize: (authorizationUrl: string | URL, account: FakeOidcAccount) => URLSearchParams;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** An OpenID Connect provider as a `fetch` stand-in: discovery, JWKS, a
|
|
2
|
+
* token endpoint issuing RS256 ID tokens for the codes `authorize`
|
|
3
|
+
* grants, and userinfo. */
|
|
4
|
+
/** Key id of the signing key in JWKS and ID token headers. */
|
|
5
|
+
const KEY_ID = "fake-oidc-key";
|
|
6
|
+
/** Unpadded base64url of `data`. */
|
|
7
|
+
function base64url(data) {
|
|
8
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
9
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
/** S256 PKCE challenge of `text`. */
|
|
12
|
+
async function sha256(text) {
|
|
13
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
14
|
+
return base64url(new Uint8Array(digest));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The provider for client `clientId` at `issuer`. `fetch` serves its
|
|
18
|
+
* endpoints, 404 elsewhere; PKCE (S256) and the redirect URI bind each
|
|
19
|
+
* code, used once. `authorize` approves an authorization URL.
|
|
20
|
+
*/
|
|
21
|
+
export async function fakeOidcProvider({
|
|
22
|
+
issuer,
|
|
23
|
+
clientId,
|
|
24
|
+
claims: providerClaims = {},
|
|
25
|
+
scope: providerScope = "openid"
|
|
26
|
+
}) {
|
|
27
|
+
const endpoints = {
|
|
28
|
+
discovery: `${issuer}/.well-known/openid-configuration`,
|
|
29
|
+
authorization: `${issuer}/authorize`,
|
|
30
|
+
token: `${issuer}/token`,
|
|
31
|
+
userinfo: `${issuer}/userinfo`,
|
|
32
|
+
jwks: `${issuer}/jwks`
|
|
33
|
+
};
|
|
34
|
+
const keys = await crypto.subtle.generateKey({
|
|
35
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
36
|
+
modulusLength: 2048,
|
|
37
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
38
|
+
hash: "SHA-256"
|
|
39
|
+
}, true, ["sign", "verify"]);
|
|
40
|
+
const publicJwk = {
|
|
41
|
+
...(await crypto.subtle.exportKey("jwk", keys.publicKey)),
|
|
42
|
+
kid: KEY_ID,
|
|
43
|
+
alg: "RS256",
|
|
44
|
+
use: "sig"
|
|
45
|
+
};
|
|
46
|
+
const grants = new Map();
|
|
47
|
+
/** Userinfo claims by access token. */
|
|
48
|
+
const userinfo = new Map();
|
|
49
|
+
/** RS256 ID token of `grant`. */
|
|
50
|
+
async function idToken({
|
|
51
|
+
claims,
|
|
52
|
+
nonce
|
|
53
|
+
}) {
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
const input = [base64url(JSON.stringify({
|
|
56
|
+
alg: "RS256",
|
|
57
|
+
kid: KEY_ID,
|
|
58
|
+
typ: "JWT"
|
|
59
|
+
})), base64url(JSON.stringify({
|
|
60
|
+
...claims,
|
|
61
|
+
iss: issuer,
|
|
62
|
+
aud: clientId,
|
|
63
|
+
nonce,
|
|
64
|
+
iat: now,
|
|
65
|
+
exp: now + 3600
|
|
66
|
+
}))].join(".");
|
|
67
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", keys.privateKey, new TextEncoder().encode(input));
|
|
68
|
+
return `${input}.${base64url(new Uint8Array(signature))}`;
|
|
69
|
+
}
|
|
70
|
+
/** Token endpoint: redeems a code once, checking client, redirect URI and
|
|
71
|
+
* PKCE verifier. */
|
|
72
|
+
async function exchange(body) {
|
|
73
|
+
const code = body.get("code") ?? "";
|
|
74
|
+
const grant = grants.get(code);
|
|
75
|
+
grants.delete(code);
|
|
76
|
+
if (!grant || body.get("client_id") !== clientId || body.get("redirect_uri") !== grant.redirectUri || (await sha256(body.get("code_verifier") ?? "")) !== grant.codeChallenge) return Response.json({
|
|
77
|
+
error: "invalid_grant"
|
|
78
|
+
}, {
|
|
79
|
+
status: 400
|
|
80
|
+
});
|
|
81
|
+
const accessToken = crypto.randomUUID();
|
|
82
|
+
userinfo.set(accessToken, grant.claims);
|
|
83
|
+
return Response.json({
|
|
84
|
+
access_token: accessToken,
|
|
85
|
+
token_type: "Bearer",
|
|
86
|
+
expires_in: 3600,
|
|
87
|
+
...(grant.scope && {
|
|
88
|
+
scope: grant.scope
|
|
89
|
+
}),
|
|
90
|
+
id_token: await idToken(grant)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Serves the provider's endpoints; other URLs answer 404. */
|
|
94
|
+
async function fetch(input, init) {
|
|
95
|
+
const request = new Request(input, init);
|
|
96
|
+
const {
|
|
97
|
+
origin,
|
|
98
|
+
pathname
|
|
99
|
+
} = new URL(request.url);
|
|
100
|
+
const url = `${origin}${pathname}`;
|
|
101
|
+
if (url === endpoints.discovery) return Response.json({
|
|
102
|
+
issuer,
|
|
103
|
+
authorization_endpoint: endpoints.authorization,
|
|
104
|
+
token_endpoint: endpoints.token,
|
|
105
|
+
userinfo_endpoint: endpoints.userinfo,
|
|
106
|
+
jwks_uri: endpoints.jwks,
|
|
107
|
+
response_types_supported: ["code"],
|
|
108
|
+
subject_types_supported: ["public"],
|
|
109
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
110
|
+
code_challenge_methods_supported: ["S256"]
|
|
111
|
+
});
|
|
112
|
+
if (url === endpoints.jwks) return Response.json({
|
|
113
|
+
keys: [publicJwk]
|
|
114
|
+
});
|
|
115
|
+
if (url === endpoints.token && request.method === "POST") return exchange(new URLSearchParams(await request.text()));
|
|
116
|
+
if (url === endpoints.userinfo) {
|
|
117
|
+
const token = request.headers.get("authorization")?.replace(/^Bearer /i, "");
|
|
118
|
+
const claims = token && userinfo.get(token);
|
|
119
|
+
return claims ? Response.json(claims) : Response.json({
|
|
120
|
+
error: "invalid_token"
|
|
121
|
+
}, {
|
|
122
|
+
status: 401
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return new Response("Not Found", {
|
|
126
|
+
status: 404
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Approves `authorizationUrl` as `account`: the callback query
|
|
130
|
+
* (`code`, `state`). */
|
|
131
|
+
function authorize(authorizationUrl, account) {
|
|
132
|
+
const params = new URL(authorizationUrl).searchParams;
|
|
133
|
+
const code = crypto.randomUUID();
|
|
134
|
+
grants.set(code, {
|
|
135
|
+
claims: {
|
|
136
|
+
...providerClaims,
|
|
137
|
+
...account.claims,
|
|
138
|
+
sub: account.sub
|
|
139
|
+
},
|
|
140
|
+
scope: account.scope ?? providerScope,
|
|
141
|
+
nonce: account.nonce ?? params.get("nonce") ?? "",
|
|
142
|
+
codeChallenge: params.get("code_challenge") ?? "",
|
|
143
|
+
redirectUri: params.get("redirect_uri") ?? ""
|
|
144
|
+
});
|
|
145
|
+
return new URLSearchParams({
|
|
146
|
+
code,
|
|
147
|
+
state: params.get("state") ?? ""
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
fetch,
|
|
152
|
+
authorize
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { type AdditionalFields, type ShellAuthApp } from "../index.server.ts";
|
|
3
|
+
import type { AuthProviderCredentials } from "../providers/provider.ts";
|
|
4
|
+
import type { SessionLike } from "../types.ts";
|
|
5
|
+
import { type JsonRequester } from "./index.ts";
|
|
6
|
+
/** A client of the app under test, as `appRequester`: its answer to
|
|
7
|
+
* `init` at `path`, redirects answered as responses. */
|
|
8
|
+
export type AppRequester = (path: string, init?: {
|
|
9
|
+
method?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
body?: string;
|
|
12
|
+
}) => Promise<{
|
|
13
|
+
status: number;
|
|
14
|
+
headers: {
|
|
15
|
+
get(name: string): string | null;
|
|
16
|
+
getSetCookie(): string[];
|
|
17
|
+
};
|
|
18
|
+
text(): Promise<string>;
|
|
19
|
+
body: {
|
|
20
|
+
cancel(): Promise<void>;
|
|
21
|
+
} | null;
|
|
22
|
+
}>;
|
|
23
|
+
/** A signed-in session for a suite: `start` resolves to its `cookie`
|
|
24
|
+
* header; `end` removes what `start` created. */
|
|
25
|
+
export interface SessionSource {
|
|
26
|
+
start(): Promise<string>;
|
|
27
|
+
end(): Promise<void>;
|
|
28
|
+
}
|
|
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;
|
|
68
|
+
/** What `testDevAuth` runs against. */
|
|
69
|
+
export interface DevAuthSuite {
|
|
70
|
+
/** The signed-in visitor. */
|
|
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;
|
|
78
|
+
/** Connection string of the app's database. */
|
|
79
|
+
db: string;
|
|
80
|
+
/** Postgres role the app queries as. */
|
|
81
|
+
role: string;
|
|
82
|
+
/** Public page URLs, served alike to every visitor. */
|
|
83
|
+
publicPages: readonly string[];
|
|
84
|
+
/** Provider id whose sign-in starts on each local origin. */
|
|
85
|
+
provider: string;
|
|
86
|
+
/** Clients of the app at each local origin, by transport name
|
|
87
|
+
* (`HTTP/1.1`, `HTTP/2`). */
|
|
88
|
+
localOrigins: readonly {
|
|
89
|
+
origin: string;
|
|
90
|
+
requesters: Readonly<Record<string, JsonRequester>>;
|
|
91
|
+
}[];
|
|
92
|
+
/** The app's auth rate limit: requests per window from one client. */
|
|
93
|
+
rateLimit: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Tests of `request`'s dev app serving auth over its Postgres: the database
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
export declare function testDevAuth(request: AppRequester, { session, origin, devAccountId, db, role, publicPages, provider, localOrigins, rateLimit, }: DevAuthSuite): void;
|
|
103
|
+
/** What `testProductionAuth` runs against. */
|
|
104
|
+
export interface ProductionAuthSuite<Fields extends AdditionalFields, Projected extends SessionLike> {
|
|
105
|
+
/** The app's public origin. */
|
|
106
|
+
origin: string;
|
|
107
|
+
/** The app's declaration; its `errorURL` page answers 200. */
|
|
108
|
+
app: ShellAuthApp<Fields, Projected>;
|
|
109
|
+
/** The auth secret. */
|
|
110
|
+
secret: string;
|
|
111
|
+
/** Provider credentials the production worker holds; the suite signs
|
|
112
|
+
* in through each declared provider they enable. */
|
|
113
|
+
credentials: AuthProviderCredentials;
|
|
114
|
+
/** better-auth plugins the served instance adds. */
|
|
115
|
+
plugins?: BetterAuthPlugin[];
|
|
116
|
+
/** Connection string of the app's database. */
|
|
117
|
+
db: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Tests of `request`'s production app serving auth, against `app` built for
|
|
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
|
|
123
|
+
* callback landing on the error page, and each provider's sign-in with its
|
|
124
|
+
* runtime client and callback.
|
|
125
|
+
*/
|
|
126
|
+
export declare function testProductionAuth<Fields extends AdditionalFields, Projected extends SessionLike>(request: AppRequester, { origin, app, secret, credentials, plugins, db, }: ProductionAuthSuite<Fields, Projected>): void;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** Test suites of an app's served auth: its dev build over the app's
|
|
2
|
+
* Postgres, and its production build; dev sign-in through the app. */
|
|
3
|
+
import { expectViewerIndependent } from "@astralarium/repo-config-vitest/pages";
|
|
4
|
+
import { withSql } from "@astralarium/repo-postgres/sql";
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { DEV_SIGN_IN_ENDPOINT } from "../dev-sign-in/endpoint.js";
|
|
8
|
+
import { shellAuth } from "../index.server.js";
|
|
9
|
+
import { AUTH_BASE_PATH } from "../request.js";
|
|
10
|
+
import { authorizePaths, cookieHeader, cookiePairs, expectAnonymousSession, expectAuthRateLimited, idTokenAuthorization, socialSignIn, unservedAuthPaths, verifyAuthSchema, withBlockedSessionRead } from "./index.js";
|
|
11
|
+
import { jsonPost } from "./json-post.js";
|
|
12
|
+
/** Dev sign-in through the app at `origin` as `accountId`
|
|
13
|
+
* (`ShellAuthApp.devSignIn`): its answer. */
|
|
14
|
+
export function devSignIn(request, origin, accountId) {
|
|
15
|
+
return request(`${AUTH_BASE_PATH}${DEV_SIGN_IN_ENDPOINT}`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
origin,
|
|
19
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
20
|
+
},
|
|
21
|
+
body: new URLSearchParams({
|
|
22
|
+
accountId
|
|
23
|
+
}).toString()
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Signs in through the app's dev sign-in at `origin` as `accountId`;
|
|
27
|
+
* rejects naming any answer other than 200. */
|
|
28
|
+
export async function devSignInAs(request, origin, accountId) {
|
|
29
|
+
const response = await devSignIn(request, origin, accountId);
|
|
30
|
+
const body = await response.text();
|
|
31
|
+
if (response.status !== 200) throw new Error(`dev sign-in as ${accountId}: HTTP ${response.status} ${body}`);
|
|
32
|
+
const {
|
|
33
|
+
user
|
|
34
|
+
} = z.object({
|
|
35
|
+
user: z.object({
|
|
36
|
+
id: z.string()
|
|
37
|
+
})
|
|
38
|
+
}).parse(JSON.parse(body));
|
|
39
|
+
const setCookies = response.headers.getSetCookie();
|
|
40
|
+
return {
|
|
41
|
+
userId: user.id,
|
|
42
|
+
cookie: cookieHeader(cookiePairs(setCookies)),
|
|
43
|
+
setCookies
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The session and user ids `cookie` carries, read past the cookie
|
|
47
|
+
* cache. */
|
|
48
|
+
export async function sessionOf(request, cookie) {
|
|
49
|
+
const response = await request(`${AUTH_BASE_PATH}/get-session?disableCookieCache=true`, {
|
|
50
|
+
headers: {
|
|
51
|
+
cookie
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return z.object({
|
|
55
|
+
session: z.object({
|
|
56
|
+
id: z.string()
|
|
57
|
+
}),
|
|
58
|
+
user: z.object({
|
|
59
|
+
id: z.string()
|
|
60
|
+
})
|
|
61
|
+
}).parse(JSON.parse(await response.text()));
|
|
62
|
+
}
|
|
63
|
+
/** A session signed in through the app's dev sign-in at `origin` as a
|
|
64
|
+
* fresh `devAccountId()` (apps with `devSignIn`); its new user is deleted
|
|
65
|
+
* from `db` at `end`. */
|
|
66
|
+
export function devSignInSession(request, {
|
|
67
|
+
origin,
|
|
68
|
+
db,
|
|
69
|
+
devAccountId
|
|
70
|
+
}) {
|
|
71
|
+
let userId;
|
|
72
|
+
return {
|
|
73
|
+
async start() {
|
|
74
|
+
const signedIn = await devSignInAs(request, origin, devAccountId());
|
|
75
|
+
userId = signedIn.userId;
|
|
76
|
+
return signedIn.cookie;
|
|
77
|
+
},
|
|
78
|
+
async end() {
|
|
79
|
+
if (!userId) return;
|
|
80
|
+
const id = userId;
|
|
81
|
+
await withSql(db, async sql => {
|
|
82
|
+
await sql`DELETE FROM auth.users WHERE id = ${id}`;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Tests of `request`'s dev app serving auth over its Postgres: the database
|
|
89
|
+
* role, public pages independent of the viewer, dev sign-in, provider
|
|
90
|
+
* callbacks on each local origin, and the rate limit, last: it exhausts
|
|
91
|
+
* the client's budget for the window. Users dev sign-in creates are
|
|
92
|
+
* deleted from `db` after.
|
|
93
|
+
*/
|
|
94
|
+
export function testDevAuth(request, {
|
|
95
|
+
session,
|
|
96
|
+
origin,
|
|
97
|
+
devAccountId,
|
|
98
|
+
db,
|
|
99
|
+
role,
|
|
100
|
+
publicPages,
|
|
101
|
+
provider,
|
|
102
|
+
localOrigins,
|
|
103
|
+
rateLimit
|
|
104
|
+
}) {
|
|
105
|
+
describe("dev auth", {
|
|
106
|
+
concurrent: false
|
|
107
|
+
}, () => {
|
|
108
|
+
let cookie = "";
|
|
109
|
+
/** Users dev sign-in created. */
|
|
110
|
+
const devUsers = new Set();
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
cookie = await session.start();
|
|
113
|
+
});
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await session.end();
|
|
116
|
+
await withSql(db, async sql => {
|
|
117
|
+
await sql`DELETE FROM auth.users WHERE id = ANY(${[...devUsers]}::uuid[])`;
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
/** Signs in as `accountId`: the session's user id and the response's
|
|
121
|
+
* `Set-Cookie` values. */
|
|
122
|
+
async function signInAs(accountId) {
|
|
123
|
+
const signedIn = await devSignInAs(request, origin, accountId);
|
|
124
|
+
devUsers.add(signedIn.userId);
|
|
125
|
+
return signedIn;
|
|
126
|
+
}
|
|
127
|
+
test(`the app queries Postgres as ${role}, a non-superuser`, async () => {
|
|
128
|
+
const blocked = await withBlockedSessionRead(db, () => request(`${AUTH_BASE_PATH}/get-session?disableCookieCache=true`, {
|
|
129
|
+
headers: {
|
|
130
|
+
cookie
|
|
131
|
+
}
|
|
132
|
+
}), blocked => blocked);
|
|
133
|
+
expect(blocked).toMatchObject({
|
|
134
|
+
role,
|
|
135
|
+
superuser: false
|
|
136
|
+
});
|
|
137
|
+
expect((await blocked.response).status).toBe(200);
|
|
138
|
+
}, 15_000);
|
|
139
|
+
test.each(publicPages)("the public page %s serves a signed-in visitor the anonymous visitor's document", url => expectViewerIndependent(request, url, cookie));
|
|
140
|
+
test(`dev sign-in signs in as a new ${provider} account's user with better-auth's session cookies and a signed marker naming the session`, async () => {
|
|
141
|
+
const signedIn = await signInAs(devAccountId());
|
|
142
|
+
const fresh = await sessionOf(request, signedIn.cookie);
|
|
143
|
+
expect(fresh.user.id).toBe(signedIn.userId);
|
|
144
|
+
expect(signedIn.setCookies).toContainEqual(expect.stringMatching(new RegExp(`\\.dev_sign_in=${fresh.session.id}\\.[^;]+;.*HttpOnly`, "i")));
|
|
145
|
+
});
|
|
146
|
+
test(`dev sign-in as an existing ${provider} account signs in as its user`, async () => {
|
|
147
|
+
const accountId = devAccountId();
|
|
148
|
+
const first = await signInAs(accountId);
|
|
149
|
+
const second = await signInAs(accountId);
|
|
150
|
+
expect(second.userId).toBe(first.userId);
|
|
151
|
+
expect((await sessionOf(request, second.cookie)).user.id).toBe(first.userId);
|
|
152
|
+
});
|
|
153
|
+
test("dev sign-in refuses a cross-origin request", async () => {
|
|
154
|
+
const response = await devSignIn(request, "https://elsewhere.invalid", devAccountId());
|
|
155
|
+
expect(response.status).toBe(403);
|
|
156
|
+
await response.body?.cancel();
|
|
157
|
+
});
|
|
158
|
+
test.each(localOrigins.flatMap(({
|
|
159
|
+
origin,
|
|
160
|
+
requesters
|
|
161
|
+
}) => Object.entries(requesters).map(([transport, requester]) => ({
|
|
162
|
+
origin,
|
|
163
|
+
transport,
|
|
164
|
+
requester
|
|
165
|
+
}))))(`the ${provider} callback stays on $origin over $transport`, async ({
|
|
166
|
+
origin,
|
|
167
|
+
requester
|
|
168
|
+
}) => {
|
|
169
|
+
const url = await socialSignIn(requester, {
|
|
170
|
+
origin,
|
|
171
|
+
provider
|
|
172
|
+
});
|
|
173
|
+
expect(url.searchParams.get("redirect_uri")).toBe(`${origin}${AUTH_BASE_PATH}/callback/${provider}`);
|
|
174
|
+
});
|
|
175
|
+
test("the app throttles repeated auth requests from one client", async () => {
|
|
176
|
+
await expectAuthRateLimited(request, `${AUTH_BASE_PATH}/get-session`, rateLimit);
|
|
177
|
+
}, 15_000);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Tests of `request`'s production app serving auth, against `app` built for
|
|
182
|
+
* production (`devSignIn` off): its schema, 404s outside the served
|
|
183
|
+
* endpoints and at dev sign-in, ID-token refusal, the anonymous session, a failed provider
|
|
184
|
+
* callback landing on the error page, and each provider's sign-in with its
|
|
185
|
+
* runtime client and callback.
|
|
186
|
+
*/
|
|
187
|
+
export function testProductionAuth(request, {
|
|
188
|
+
origin,
|
|
189
|
+
app,
|
|
190
|
+
secret,
|
|
191
|
+
credentials,
|
|
192
|
+
plugins,
|
|
193
|
+
db
|
|
194
|
+
}) {
|
|
195
|
+
const production = {
|
|
196
|
+
...app,
|
|
197
|
+
devSignIn: undefined
|
|
198
|
+
};
|
|
199
|
+
const auth = shellAuth(() => ({
|
|
200
|
+
...production,
|
|
201
|
+
baseURL: origin,
|
|
202
|
+
secret,
|
|
203
|
+
credentials,
|
|
204
|
+
plugins
|
|
205
|
+
}))({});
|
|
206
|
+
const ids = app.providers?.enabled(credentials) ?? [];
|
|
207
|
+
const clients = ids.map(provider => ({
|
|
208
|
+
provider,
|
|
209
|
+
clientId: credentials[provider].clientId
|
|
210
|
+
}));
|
|
211
|
+
const provider = clients[0]?.provider ?? "";
|
|
212
|
+
describe("production auth", () => {
|
|
213
|
+
test("the auth database schema holds every field the auth configuration maps", () => verifyAuthSchema(db, auth));
|
|
214
|
+
test.each(unservedAuthPaths(auth, production))("the production app returns not found for unused auth path %s", async path => {
|
|
215
|
+
const response = await request(path, jsonPost(origin, {}));
|
|
216
|
+
expect(response.status).toBe(404);
|
|
217
|
+
expect(await response.text()).toBe("Not Found");
|
|
218
|
+
});
|
|
219
|
+
test("the production app returns not found for dev sign-in", async () => {
|
|
220
|
+
const response = await devSignIn(request, origin, "1");
|
|
221
|
+
expect(response.status).toBe(404);
|
|
222
|
+
await response.body?.cancel();
|
|
223
|
+
});
|
|
224
|
+
test.each(authorizePaths(production))("the production app refuses ID-token authorization at %s", async path => {
|
|
225
|
+
const response = await request(path, idTokenAuthorization({
|
|
226
|
+
origin,
|
|
227
|
+
provider
|
|
228
|
+
}));
|
|
229
|
+
expect(response.status).toBe(400);
|
|
230
|
+
await response.body?.cancel();
|
|
231
|
+
});
|
|
232
|
+
test("the production app serves no session to an anonymous request", () => expectAnonymousSession(request));
|
|
233
|
+
test.skipIf(!provider)("the production app lands a failed provider callback on its error page", async () => {
|
|
234
|
+
const callback = await request(`${AUTH_BASE_PATH}/callback/${provider}?error=access_denied`);
|
|
235
|
+
expect(callback.status).toBe(302);
|
|
236
|
+
await callback.body?.cancel();
|
|
237
|
+
const location = new URL(callback.headers.get("location") ?? "", origin);
|
|
238
|
+
expect(location.pathname).toBe(new URL(app.errorURL, origin).pathname);
|
|
239
|
+
const page = await request(`${location.pathname}${location.search}`);
|
|
240
|
+
expect(page.status).toBe(200);
|
|
241
|
+
await page.body?.cancel();
|
|
242
|
+
});
|
|
243
|
+
test.each(clients)("the production app signs in through $provider with runtime OAuth credentials and its configured callback origin", async ({
|
|
244
|
+
provider,
|
|
245
|
+
clientId
|
|
246
|
+
}) => {
|
|
247
|
+
const url = await socialSignIn(request, {
|
|
248
|
+
origin,
|
|
249
|
+
provider
|
|
250
|
+
});
|
|
251
|
+
expect(url.searchParams.get("redirect_uri")).toBe(`${origin}${AUTH_BASE_PATH}/callback/${provider}`);
|
|
252
|
+
expect(url.searchParams.get("client_id")).toBe(clientId);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,89 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astralarium/repo-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-auth#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/astralarium/repo.git",
|
|
9
|
+
"directory": "packages/repo-auth"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"sql"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.server.js",
|
|
19
|
+
"./client": "./dist/client.js",
|
|
20
|
+
"./dev-sign-in": "./dist/dev-sign-in/index.js",
|
|
21
|
+
"./dev-sign-in/client": "./dist/dev-sign-in/client.js",
|
|
22
|
+
"./providers": "./dist/providers/index.js",
|
|
23
|
+
"./request": "./dist/request.js",
|
|
24
|
+
"./schema": "./dist/schema.js",
|
|
25
|
+
"./start": "./dist/start/index.js",
|
|
26
|
+
"./start/server": "./dist/start/index.server.js",
|
|
27
|
+
"./testkit": "./dist/testkit/index.js",
|
|
28
|
+
"./testkit/suites": "./dist/testkit/suites.js"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"kysely": "^0.29.5",
|
|
35
|
+
"pg": "^8.20.0",
|
|
36
|
+
"zod": "^4.6.7",
|
|
37
|
+
"@astralarium/repo-package-files": "^0.1.0",
|
|
38
|
+
"@astralarium/repo-telemetry": "^0.2.0",
|
|
39
|
+
"@astralarium/repo-postgres": "^0.2.0",
|
|
40
|
+
"@astralarium/repo-env": "^0.2.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@tanstack/query-core": "^5.107.1",
|
|
44
|
+
"@tanstack/react-start": "^1.169.4",
|
|
45
|
+
"@types/node": "^24.2.0",
|
|
46
|
+
"@types/pg": "^8.20.0",
|
|
47
|
+
"@types/react": "19.2.14",
|
|
48
|
+
"better-auth": "^1.7.7",
|
|
49
|
+
"eslint": "^10.2.1",
|
|
50
|
+
"postgres": "^3.4.9",
|
|
51
|
+
"react": "19.2.5",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vite": "^8.3.4",
|
|
54
|
+
"vitest": "^5.0.3",
|
|
55
|
+
"@astralarium/repo-config-typescript": "^0.1.0",
|
|
56
|
+
"@astralarium/repo-config-vitest": "^0.2.0",
|
|
57
|
+
"@astralarium/repo-config-eslint": "^0.3.0",
|
|
58
|
+
"@astralarium/repo-package-build": "^0.1.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@tanstack/react-start": "^1.169.4",
|
|
62
|
+
"better-auth": "^1.7.7",
|
|
63
|
+
"react": "^19.2.0",
|
|
64
|
+
"vitest": "^5.0.3",
|
|
65
|
+
"@astralarium/repo-config-vitest": "^0.2.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependenciesMeta": {
|
|
68
|
+
"@astralarium/repo-config-vitest": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@tanstack/react-start": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"react": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"vitest": {
|
|
78
|
+
"optional": true
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=24.2"
|
|
83
|
+
},
|
|
84
|
+
"scripts": {
|
|
85
|
+
"check-types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
|
|
86
|
+
"lint": "eslint",
|
|
87
|
+
"test": "vitest"
|
|
88
|
+
}
|
|
6
89
|
}
|