@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -2
  3. package/dist/client.d.ts +2173 -0
  4. package/dist/client.js +203 -0
  5. package/dist/dev-sign-in/client.d.ts +21 -0
  6. package/dist/dev-sign-in/client.js +17 -0
  7. package/dist/dev-sign-in/endpoint.d.ts +2 -0
  8. package/dist/dev-sign-in/endpoint.js +2 -0
  9. package/dist/dev-sign-in/index.d.ts +4 -0
  10. package/dist/dev-sign-in/index.js +4 -0
  11. package/dist/dev-sign-in/provider-user.d.ts +30 -0
  12. package/dist/dev-sign-in/provider-user.js +55 -0
  13. package/dist/dev-sign-in/sign-in.d.ts +62 -0
  14. package/dist/dev-sign-in/sign-in.js +116 -0
  15. package/dist/index.server.d.ts +645 -0
  16. package/dist/index.server.js +307 -0
  17. package/dist/providers/google.d.ts +8 -0
  18. package/dist/providers/google.js +11 -0
  19. package/dist/providers/index.d.ts +3 -0
  20. package/dist/providers/index.js +3 -0
  21. package/dist/providers/provider.d.ts +135 -0
  22. package/dist/providers/provider.js +86 -0
  23. package/dist/request.d.ts +42 -0
  24. package/dist/request.js +75 -0
  25. package/dist/schema.d.ts +6 -0
  26. package/dist/schema.js +23 -0
  27. package/dist/start/index.d.ts +6 -0
  28. package/dist/start/index.js +6 -0
  29. package/dist/start/index.server.d.ts +28 -0
  30. package/dist/start/index.server.js +29 -0
  31. package/dist/start/middleware.d.ts +33 -0
  32. package/dist/start/middleware.js +70 -0
  33. package/dist/start/return-path.d.ts +16 -0
  34. package/dist/start/return-path.js +32 -0
  35. package/dist/start/viewer.d.ts +8 -0
  36. package/dist/start/viewer.js +15 -0
  37. package/dist/testkit/index.d.ts +131 -0
  38. package/dist/testkit/index.js +238 -0
  39. package/dist/testkit/json-post.d.ts +8 -0
  40. package/dist/testkit/json-post.js +12 -0
  41. package/dist/testkit/oidc.d.ts +35 -0
  42. package/dist/testkit/oidc.js +154 -0
  43. package/dist/testkit/suites.d.ts +126 -0
  44. package/dist/testkit/suites.js +255 -0
  45. package/dist/types.d.ts +7 -0
  46. package/dist/types.js +1 -0
  47. package/package.json +86 -3
  48. package/sql/jobs/schema.sql +19 -0
  49. package/sql/schema.sql +97 -0
  50. 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
+ }
@@ -0,0 +1,7 @@
1
+ /** Projected session shape every consumer relies on. */
2
+ export interface SessionLike {
3
+ session: object;
4
+ user: {
5
+ id: string;
6
+ };
7
+ }
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }