@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/README.md
CHANGED
|
@@ -9,3 +9,52 @@ App wiring:
|
|
|
9
9
|
- `/api/auth/$` responds via `serveAuthRequest`.
|
|
10
10
|
- Provider credentials: `AUTH_<ID>_ID`, `AUTH_<ID>_SECRET`.
|
|
11
11
|
Redirect URI: `/api/auth/callback/<id>`.
|
|
12
|
+
|
|
13
|
+
Dev sign-in:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
export const webAuthApp = {
|
|
17
|
+
...app,
|
|
18
|
+
devSignIn: import.meta.env.DEV
|
|
19
|
+
? devProviderSignIn({
|
|
20
|
+
provider: "google",
|
|
21
|
+
accountId: z.string().regex(/^\d+$/),
|
|
22
|
+
newUser: (id) => ({ name: `Dev ${id}`, email: `${id}@dev.invalid` }),
|
|
23
|
+
})
|
|
24
|
+
: undefined,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// dev sign-in page
|
|
28
|
+
await signInWithDevAccount(authClient, accountId);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
TanStack Start:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export const fetchViewer = createServerFn({ method: "GET" }).handler(
|
|
35
|
+
viewerSession(withAuth),
|
|
36
|
+
);
|
|
37
|
+
const db = dbMiddleware(
|
|
38
|
+
createServerOnlyFn(() => databaseConnectionString(env)),
|
|
39
|
+
);
|
|
40
|
+
export const authed = authedMiddleware(sessionMiddleware(fetchViewer));
|
|
41
|
+
|
|
42
|
+
export const saveDraft = createServerFn({ method: "POST" })
|
|
43
|
+
.middleware([authed, db])
|
|
44
|
+
.handler(({ context: { session, sql } }) =>
|
|
45
|
+
saveDraftOf(sql, session.user.id),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const Route = createFileRoute("/_app")({
|
|
49
|
+
beforeLoad: viewerRouteContext(fetchViewer),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// router `hydrate`
|
|
53
|
+
watchViewerRouter(router, queryClient, "/_app");
|
|
54
|
+
|
|
55
|
+
// client
|
|
56
|
+
saveDraft().catch((error) => {
|
|
57
|
+
if (isUnauthenticated(error)) openSignIn();
|
|
58
|
+
else throw error;
|
|
59
|
+
});
|
|
60
|
+
```
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Browser half of shell-auth: the session store, auth calls and OAuth popups. */
|
|
2
1
|
import type { BetterAuthOptions } from "better-auth";
|
|
3
2
|
import type { SessionLike } from "./types.ts";
|
|
4
3
|
/** Opens `url`, a page starting a provider's OAuth redirect, in a popup
|
|
@@ -29,8 +28,8 @@ interface SessionStore<T> {
|
|
|
29
28
|
get: () => T;
|
|
30
29
|
listen: (listener: () => void) => () => void;
|
|
31
30
|
}
|
|
32
|
-
/** What `
|
|
33
|
-
*
|
|
31
|
+
/** What `watchViewerRouter` reads of a router: TanStack Router's load
|
|
32
|
+
* event, its matched routes, and invalidation. */
|
|
34
33
|
export interface ViewerRouter {
|
|
35
34
|
subscribe(event: "onLoad", fn: () => void): () => void;
|
|
36
35
|
state: {
|
|
@@ -39,14 +38,33 @@ export interface ViewerRouter {
|
|
|
39
38
|
context: unknown;
|
|
40
39
|
}[];
|
|
41
40
|
};
|
|
41
|
+
invalidate(): Promise<unknown>;
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
/** What `watchViewerRouter` reads of a TanStack Query query. */
|
|
44
|
+
interface ViewerQuery {
|
|
45
|
+
state: {
|
|
46
|
+
status: string;
|
|
47
|
+
fetchStatus: string;
|
|
48
|
+
};
|
|
49
|
+
reset(): void;
|
|
50
|
+
}
|
|
51
|
+
/** What `watchViewerRouter` reads of a TanStack Query client: its cached
|
|
52
|
+
* queries and refetch of the active ones. */
|
|
53
|
+
export interface ViewerQueryClient {
|
|
54
|
+
getQueryCache(): {
|
|
55
|
+
getAll(): readonly ViewerQuery[];
|
|
56
|
+
};
|
|
57
|
+
refetchQueries(filters: {
|
|
58
|
+
type: "active";
|
|
59
|
+
predicate: (query: ViewerQuery) => boolean;
|
|
60
|
+
}): Promise<unknown>;
|
|
61
|
+
}
|
|
62
|
+
/** Context of the layout route carrying the viewer's session
|
|
63
|
+
* (`viewerRouteContext`). */
|
|
64
|
+
export interface ViewerContext<Session> {
|
|
65
|
+
/** `null` signed out; `undefined` on a page reached by client
|
|
66
|
+
* navigation. */
|
|
67
|
+
session: Session | null | undefined;
|
|
50
68
|
}
|
|
51
69
|
/** Shared live session store and auth operations for `Auth`. */
|
|
52
70
|
export declare function createShellAuthClient<Auth extends ShellAuthLike>(): {
|
|
@@ -2142,16 +2160,14 @@ export declare function createShellAuthClient<Auth extends ShellAuthLike>(): {
|
|
|
2142
2160
|
}>;
|
|
2143
2161
|
refreshSession: () => Promise<void>;
|
|
2144
2162
|
watchSession: (session: Auth["$Infer"]["Session"] | null | undefined, onChange: () => void) => () => void;
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
email: string;
|
|
2149
|
-
password: string;
|
|
2150
|
-
name: string;
|
|
2163
|
+
watchViewerRouter: (router: ViewerRouter, queryClient: ViewerQueryClient, routeId: string) => () => void;
|
|
2164
|
+
post: (path: string, body: Record<string, string>, { form }?: {
|
|
2165
|
+
form?: boolean | undefined;
|
|
2151
2166
|
}) => Promise<{
|
|
2152
2167
|
message?: string | undefined;
|
|
2153
2168
|
status: number;
|
|
2154
2169
|
statusText: string;
|
|
2155
2170
|
} | undefined>;
|
|
2171
|
+
signInWithProvider: (provider: string, callbackURL: string) => Promise<string | undefined>;
|
|
2156
2172
|
};
|
|
2157
2173
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Browser half of shell-auth: the session store, auth calls and OAuth popups. */
|
|
2
|
+
import { log } from "@astralarium/repo-telemetry";
|
|
1
3
|
import { getGlobalBroadcastChannel } from "better-auth/client";
|
|
2
4
|
import { InferServerPlugin } from "better-auth/client/plugins";
|
|
3
5
|
import { createAuthClient } from "better-auth/react";
|
|
@@ -124,32 +126,55 @@ export function createShellAuthClient() {
|
|
|
124
126
|
});
|
|
125
127
|
}
|
|
126
128
|
/**
|
|
127
|
-
* Watches the signed-in user from the first loaded page under `routeId
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* store's first read sets the
|
|
131
|
-
*
|
|
129
|
+
* Watches the signed-in user from the first loaded page under `routeId`,
|
|
130
|
+
* the layout route carrying `viewerRouteContext`; other pages read no
|
|
131
|
+
* session. A server-rendered page's session seeds the watch; one reached
|
|
132
|
+
* by client navigation carries none, and the store's first read sets the
|
|
133
|
+
* user. Each user change after clears `queryClient`'s queries and
|
|
134
|
+
* invalidates `router`, both holding the previous user's data; the
|
|
135
|
+
* route loaders refetch the queries they read, then the active queries
|
|
136
|
+
* still unread refetch: one fetch per query. Returns the unsubscribe.
|
|
132
137
|
*/
|
|
133
|
-
function
|
|
134
|
-
routeId,
|
|
135
|
-
select
|
|
136
|
-
}, onChange) {
|
|
138
|
+
function watchViewerRouter(router, queryClient, routeId) {
|
|
137
139
|
let stop = router.subscribe("onLoad", () => {
|
|
138
140
|
const match = router.state.matches.find(route => route.routeId === routeId);
|
|
139
141
|
if (!match) return;
|
|
140
142
|
stop();
|
|
141
|
-
|
|
143
|
+
const {
|
|
144
|
+
session
|
|
145
|
+
} = match.context;
|
|
146
|
+
stop = watchSession(session, () => {
|
|
147
|
+
for (const query of queryClient.getQueryCache().getAll()) query.reset();
|
|
148
|
+
const failed = exception => log.warn("auth.viewer_reload_failed", "Reloading the page for a new viewer failed", {
|
|
149
|
+
exception
|
|
150
|
+
});
|
|
151
|
+
void router.invalidate().catch(failed).then(() => queryClient.refetchQueries({
|
|
152
|
+
type: "active",
|
|
153
|
+
predicate: ({
|
|
154
|
+
state
|
|
155
|
+
}) => state.status === "pending" && state.fetchStatus === "idle"
|
|
156
|
+
})).catch(failed);
|
|
157
|
+
});
|
|
142
158
|
});
|
|
143
159
|
return () => stop();
|
|
144
160
|
}
|
|
145
|
-
/** POSTs `body` to the auth endpoint at `path
|
|
146
|
-
* Endpoint methods (`signIn.social`) type per
|
|
147
|
-
|
|
161
|
+
/** POSTs `body` to the auth endpoint at `path`, a form with `form`;
|
|
162
|
+
* resolves to its error. Endpoint methods (`signIn.social`) type per
|
|
163
|
+
* app, unresolved here; plugin endpoints (`devProviderSignIn`) call
|
|
164
|
+
* through it. */
|
|
165
|
+
async function post(path, body, {
|
|
166
|
+
form = false
|
|
167
|
+
} = {}) {
|
|
148
168
|
const {
|
|
149
169
|
error
|
|
150
170
|
} = await authClient.$fetch(path, {
|
|
151
171
|
method: "POST",
|
|
152
|
-
body
|
|
172
|
+
body,
|
|
173
|
+
...(form && {
|
|
174
|
+
headers: {
|
|
175
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
176
|
+
}
|
|
177
|
+
})
|
|
153
178
|
});
|
|
154
179
|
return error ?? undefined;
|
|
155
180
|
}
|
|
@@ -166,29 +191,13 @@ export function createShellAuthClient() {
|
|
|
166
191
|
return cause instanceof Error ? cause.message : String(cause);
|
|
167
192
|
}
|
|
168
193
|
}
|
|
169
|
-
/** Signs up an email account, or into it when it exists, then reads
|
|
170
|
-
* the new session in this tab: `$fetch` skips Better Auth's session
|
|
171
|
-
* signal; `sessionBroadcast` tells the others. Resolves to the sign-in
|
|
172
|
-
* error when both fail. */
|
|
173
|
-
async function signUpOrInWithEmail(account) {
|
|
174
|
-
const {
|
|
175
|
-
email,
|
|
176
|
-
password
|
|
177
|
-
} = account;
|
|
178
|
-
const error = (await post("/sign-up/email", account)) && (await post("/sign-in/email", {
|
|
179
|
-
email,
|
|
180
|
-
password
|
|
181
|
-
}));
|
|
182
|
-
if (!error) await sessionStore.get().refetch();
|
|
183
|
-
return error;
|
|
184
|
-
}
|
|
185
194
|
return {
|
|
186
195
|
authClient,
|
|
187
196
|
sessionStore,
|
|
188
197
|
refreshSession,
|
|
189
198
|
watchSession,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
watchViewerRouter,
|
|
200
|
+
post,
|
|
201
|
+
signInWithProvider
|
|
193
202
|
};
|
|
194
203
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Dev builds' browser sign-in as provider accounts. */
|
|
2
|
+
import type { createShellAuthClient } from "../client.ts";
|
|
3
|
+
/** What dev sign-in calls of a shell auth client
|
|
4
|
+
* (`createShellAuthClient`). */
|
|
5
|
+
export interface DevSignInClient {
|
|
6
|
+
post: ReturnType<typeof createShellAuthClient>["post"];
|
|
7
|
+
sessionStore: {
|
|
8
|
+
get(): {
|
|
9
|
+
refetch(): Promise<unknown>;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Signs in through `client`'s dev sign-in (`devProviderSignIn`) as the
|
|
14
|
+
* provider account `accountId`, then reads the new session in this tab:
|
|
15
|
+
* `post` skips Better Auth's session signal; `sessionBroadcast` tells the
|
|
16
|
+
* others. Resolves to the error when sign-in fails. */
|
|
17
|
+
export declare function signInWithDevAccount({ post, sessionStore }: DevSignInClient, accountId: string): Promise<{
|
|
18
|
+
message?: string | undefined;
|
|
19
|
+
status: number;
|
|
20
|
+
statusText: string;
|
|
21
|
+
} | undefined>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DEV_SIGN_IN_ENDPOINT } from "./endpoint.js";
|
|
2
|
+
/** Signs in through `client`'s dev sign-in (`devProviderSignIn`) as the
|
|
3
|
+
* provider account `accountId`, then reads the new session in this tab:
|
|
4
|
+
* `post` skips Better Auth's session signal; `sessionBroadcast` tells the
|
|
5
|
+
* others. Resolves to the error when sign-in fails. */
|
|
6
|
+
export async function signInWithDevAccount({
|
|
7
|
+
post,
|
|
8
|
+
sessionStore
|
|
9
|
+
}, accountId) {
|
|
10
|
+
const error = await post(DEV_SIGN_IN_ENDPOINT, {
|
|
11
|
+
accountId
|
|
12
|
+
}, {
|
|
13
|
+
form: true
|
|
14
|
+
});
|
|
15
|
+
if (!error) await sessionStore.get().refetch();
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Dev builds' sign-in as provider accounts, and provider accounts
|
|
2
|
+
* resolved to users for seeds. */
|
|
3
|
+
export { type ContextAuth, ensureProviderUser, type ProviderUser, } from "./provider-user.ts";
|
|
4
|
+
export { type DevProviderSignIn, devProviderSignIn, type DevProviderSignInOptions, isDevSignIn, } from "./sign-in.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Provider accounts resolved to users through better-auth's adapter. */
|
|
2
|
+
import type { AuthContext } from "better-auth";
|
|
3
|
+
/** A provider account and the user it creates when new. */
|
|
4
|
+
export interface ProviderUser {
|
|
5
|
+
/** Provider id (`google`, `github`). */
|
|
6
|
+
providerId: string;
|
|
7
|
+
/** The provider's account id. */
|
|
8
|
+
accountId: string;
|
|
9
|
+
/** Name of a new user. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Email of a new user; a user holding it gains the account. */
|
|
12
|
+
email: string;
|
|
13
|
+
}
|
|
14
|
+
/** What a dev sign-in function reads of an auth instance: its context's
|
|
15
|
+
* `Keys`; `ensureProviderUser` reads `internalAdapter`. */
|
|
16
|
+
export interface ContextAuth<Keys extends keyof AuthContext = "internalAdapter"> {
|
|
17
|
+
$context: Promise<Pick<AuthContext, Keys>>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The id of the user owning `providerId` account `accountId`, else of the
|
|
21
|
+
* user holding `email`, else of a new user (`name`, verified `email`); the
|
|
22
|
+
* latter two gain a token-less account. `email` must be trusted (the
|
|
23
|
+
* account's own claim): its holder gains the account. Rows go through
|
|
24
|
+
* better-auth's adapter, so the app's `databaseHooks` run. Concurrent
|
|
25
|
+
* calls for one new account resolve to one user. Queries run inside
|
|
26
|
+
* `withShellAuth`. Seeds call it outside any request: better-auth's
|
|
27
|
+
* `handleOAuthUserInfo`, which dev sign-in uses, takes an endpoint context
|
|
28
|
+
* and creates a session.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ensureProviderUser(auth: ContextAuth, { providerId, accountId, name, email }: ProviderUser): Promise<string>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The id of the user owning `providerId` account `accountId`, else of the
|
|
3
|
+
* user holding `email`, else of a new user (`name`, verified `email`); the
|
|
4
|
+
* latter two gain a token-less account. `email` must be trusted (the
|
|
5
|
+
* account's own claim): its holder gains the account. Rows go through
|
|
6
|
+
* better-auth's adapter, so the app's `databaseHooks` run. Concurrent
|
|
7
|
+
* calls for one new account resolve to one user. Queries run inside
|
|
8
|
+
* `withShellAuth`. Seeds call it outside any request: better-auth's
|
|
9
|
+
* `handleOAuthUserInfo`, which dev sign-in uses, takes an endpoint context
|
|
10
|
+
* and creates a session.
|
|
11
|
+
*/
|
|
12
|
+
export async function ensureProviderUser(auth, {
|
|
13
|
+
providerId,
|
|
14
|
+
accountId,
|
|
15
|
+
name,
|
|
16
|
+
email
|
|
17
|
+
}) {
|
|
18
|
+
const {
|
|
19
|
+
internalAdapter
|
|
20
|
+
} = await auth.$context;
|
|
21
|
+
const owner = () => internalAdapter.findAccountOwnerByKey({
|
|
22
|
+
providerId,
|
|
23
|
+
accountId
|
|
24
|
+
}).then(found => found?.kind === "owned" ? found.user.id : undefined);
|
|
25
|
+
const existing = await owner();
|
|
26
|
+
if (existing) return existing;
|
|
27
|
+
try {
|
|
28
|
+
const holder = await internalAdapter.findUserByEmail(email);
|
|
29
|
+
if (holder) {
|
|
30
|
+
await internalAdapter.linkAccount({
|
|
31
|
+
userId: holder.user.id,
|
|
32
|
+
providerId,
|
|
33
|
+
accountId
|
|
34
|
+
});
|
|
35
|
+
return holder.user.id;
|
|
36
|
+
}
|
|
37
|
+
const {
|
|
38
|
+
user
|
|
39
|
+
} = await internalAdapter.createOAuthUser({
|
|
40
|
+
name,
|
|
41
|
+
email,
|
|
42
|
+
emailVerified: true
|
|
43
|
+
}, {
|
|
44
|
+
providerId,
|
|
45
|
+
accountId
|
|
46
|
+
});
|
|
47
|
+
return user.id;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// A concurrent call created the account first: the unique account or
|
|
50
|
+
// email index rejected this one. A failed re-read leaves `error`.
|
|
51
|
+
const winner = await owner().catch(() => undefined);
|
|
52
|
+
if (winner) return winner;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ContextAuth } from "./provider-user.ts";
|
|
3
|
+
/** Dev sign-in's provider and its accounts: `devProviderSignIn`'s options. */
|
|
4
|
+
export interface DevProviderSignInOptions {
|
|
5
|
+
/** Provider id the signed-in accounts belong to. */
|
|
6
|
+
provider: string;
|
|
7
|
+
/** Valid account ids; others answer 400. Default: any non-empty id. */
|
|
8
|
+
accountId?: z.ZodType<string>;
|
|
9
|
+
/** Name and email of a new account's user. */
|
|
10
|
+
newUser: (accountId: string) => {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* better-auth plugin for dev builds, `ShellAuthApp.devSignIn`: `POST
|
|
17
|
+
* /dev/sign-in` with form field `accountId` signs in as that `provider`
|
|
18
|
+
* account through better-auth's OAuth sign-in (`handleOAuthUserInfo`): its
|
|
19
|
+
* owner, else the user holding `newUser`'s email under the app's account
|
|
20
|
+
* linking, else a new user (`newUser`, verified email); new accounts hold
|
|
21
|
+
* no tokens, stored tokens stay. Requests from a better-auth trusted origin
|
|
22
|
+
* only (`isTrustedOrigin`: the base URL, allowed hosts, `trustedOrigins`);
|
|
23
|
+
* others and a missing `Origin` answer 403; a refused sign-in answers 401.
|
|
24
|
+
* better-auth sets the session cookies, plus a marker cookie holding the
|
|
25
|
+
* session id signed like the session token (`isDevSignIn`), expiring at
|
|
26
|
+
* the session's sign-in expiry. Answers the session's `token` and `user`
|
|
27
|
+
* id.
|
|
28
|
+
*/
|
|
29
|
+
export declare function devProviderSignIn({ provider, accountId, newUser, }: DevProviderSignInOptions): {
|
|
30
|
+
id: "dev-provider-sign-in";
|
|
31
|
+
endpoints: {
|
|
32
|
+
devProviderSignIn: import("better-auth").StrictEndpoint<"/dev/sign-in", {
|
|
33
|
+
method: "POST";
|
|
34
|
+
body: z.ZodObject<{
|
|
35
|
+
accountId: z.ZodType<string, unknown, z.core.$ZodTypeInternals<string, unknown>>;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
requireRequest: true;
|
|
38
|
+
metadata: {
|
|
39
|
+
allowedMediaTypes: string[];
|
|
40
|
+
};
|
|
41
|
+
}, {
|
|
42
|
+
token: string;
|
|
43
|
+
user: {
|
|
44
|
+
id: string;
|
|
45
|
+
};
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/** The dev sign-in plugin `devProviderSignIn` builds. */
|
|
50
|
+
export type DevProviderSignIn = ReturnType<typeof devProviderSignIn>;
|
|
51
|
+
/**
|
|
52
|
+
* Whether `headers` carry `auth`'s marker cookie holding `session`'s id
|
|
53
|
+
* signed with `auth`'s secret: the auth server signed that id, as dev
|
|
54
|
+
* sign-in does for each session it creates. The marker's name follows
|
|
55
|
+
* `auth`'s cookie naming (prefix, `__Secure-` on HTTPS). False past the
|
|
56
|
+
* marker's sign-in expiry, while an extended session lives on.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isDevSignIn(auth: ContextAuth<"createAuthCookie" | "secret">, headers: Headers, session: {
|
|
59
|
+
session: {
|
|
60
|
+
id: string;
|
|
61
|
+
};
|
|
62
|
+
} | null): Promise<boolean>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
2
|
+
import { parseCookies, setSessionCookie } from "better-auth/cookies";
|
|
3
|
+
import { constantTimeEqual, makeSignature } from "better-auth/crypto";
|
|
4
|
+
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { DEV_SIGN_IN_ENDPOINT } from "./endpoint.js";
|
|
7
|
+
/** better-auth cookie holding the id of the session dev sign-in created,
|
|
8
|
+
* signed with the auth secret. Its lifetime is fixed at sign-in to the
|
|
9
|
+
* session's `expiresIn`; a session extended on refresh (`updateAge`)
|
|
10
|
+
* outlives it. */
|
|
11
|
+
const MARKER = "dev_sign_in";
|
|
12
|
+
/**
|
|
13
|
+
* better-auth plugin for dev builds, `ShellAuthApp.devSignIn`: `POST
|
|
14
|
+
* /dev/sign-in` with form field `accountId` signs in as that `provider`
|
|
15
|
+
* account through better-auth's OAuth sign-in (`handleOAuthUserInfo`): its
|
|
16
|
+
* owner, else the user holding `newUser`'s email under the app's account
|
|
17
|
+
* linking, else a new user (`newUser`, verified email); new accounts hold
|
|
18
|
+
* no tokens, stored tokens stay. Requests from a better-auth trusted origin
|
|
19
|
+
* only (`isTrustedOrigin`: the base URL, allowed hosts, `trustedOrigins`);
|
|
20
|
+
* others and a missing `Origin` answer 403; a refused sign-in answers 401.
|
|
21
|
+
* better-auth sets the session cookies, plus a marker cookie holding the
|
|
22
|
+
* session id signed like the session token (`isDevSignIn`), expiring at
|
|
23
|
+
* the session's sign-in expiry. Answers the session's `token` and `user`
|
|
24
|
+
* id.
|
|
25
|
+
*/
|
|
26
|
+
export function devProviderSignIn({
|
|
27
|
+
provider,
|
|
28
|
+
accountId = z.string().min(1),
|
|
29
|
+
newUser
|
|
30
|
+
}) {
|
|
31
|
+
return {
|
|
32
|
+
id: "dev-provider-sign-in",
|
|
33
|
+
endpoints: {
|
|
34
|
+
devProviderSignIn: createAuthEndpoint(DEV_SIGN_IN_ENDPOINT, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: z.object({
|
|
37
|
+
accountId
|
|
38
|
+
}),
|
|
39
|
+
requireRequest: true,
|
|
40
|
+
metadata: {
|
|
41
|
+
allowedMediaTypes: ["application/x-www-form-urlencoded"]
|
|
42
|
+
}
|
|
43
|
+
}, async ctx => {
|
|
44
|
+
const {
|
|
45
|
+
request,
|
|
46
|
+
context
|
|
47
|
+
} = ctx;
|
|
48
|
+
const origin = request.headers.get("origin");
|
|
49
|
+
if (!origin || !context.isTrustedOrigin(origin)) throw APIError.fromStatus("FORBIDDEN", {
|
|
50
|
+
message: "Untrusted origin sign-in forbidden."
|
|
51
|
+
});
|
|
52
|
+
const id = ctx.body.accountId;
|
|
53
|
+
const signIn = () => handleOAuthUserInfo(ctx, {
|
|
54
|
+
userInfo: {
|
|
55
|
+
id,
|
|
56
|
+
...newUser(id),
|
|
57
|
+
emailVerified: true
|
|
58
|
+
},
|
|
59
|
+
account: {
|
|
60
|
+
providerId: provider,
|
|
61
|
+
accountId: id
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const first = await signIn();
|
|
65
|
+
// A concurrent sign-in as the same new account fails this one's
|
|
66
|
+
// create; the retry signs in as the winner's user.
|
|
67
|
+
const {
|
|
68
|
+
data,
|
|
69
|
+
error
|
|
70
|
+
} = first.data ? first : await signIn();
|
|
71
|
+
if (!data) throw APIError.fromStatus("UNAUTHORIZED", {
|
|
72
|
+
message: error
|
|
73
|
+
});
|
|
74
|
+
const {
|
|
75
|
+
session,
|
|
76
|
+
user
|
|
77
|
+
} = data;
|
|
78
|
+
await setSessionCookie(ctx, {
|
|
79
|
+
session,
|
|
80
|
+
user
|
|
81
|
+
});
|
|
82
|
+
const marker = context.createAuthCookie(MARKER);
|
|
83
|
+
await ctx.setSignedCookie(marker.name, session.id, context.secret, {
|
|
84
|
+
...marker.attributes,
|
|
85
|
+
maxAge: context.sessionConfig.expiresIn
|
|
86
|
+
});
|
|
87
|
+
return ctx.json({
|
|
88
|
+
token: session.token,
|
|
89
|
+
user: {
|
|
90
|
+
id: user.id
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether `headers` carry `auth`'s marker cookie holding `session`'s id
|
|
99
|
+
* signed with `auth`'s secret: the auth server signed that id, as dev
|
|
100
|
+
* sign-in does for each session it creates. The marker's name follows
|
|
101
|
+
* `auth`'s cookie naming (prefix, `__Secure-` on HTTPS). False past the
|
|
102
|
+
* marker's sign-in expiry, while an extended session lives on.
|
|
103
|
+
*/
|
|
104
|
+
export async function isDevSignIn(auth, headers, session) {
|
|
105
|
+
if (!session) return false;
|
|
106
|
+
const {
|
|
107
|
+
createAuthCookie,
|
|
108
|
+
secret
|
|
109
|
+
} = await auth.$context;
|
|
110
|
+
const value = parseCookies(headers.get("cookie") ?? "").get(createAuthCookie(MARKER).name);
|
|
111
|
+
if (value === undefined) return false;
|
|
112
|
+
const {
|
|
113
|
+
id
|
|
114
|
+
} = session.session;
|
|
115
|
+
return constantTimeEqual(value, `${id}.${await makeSignature(id, secret)}`);
|
|
116
|
+
}
|
package/dist/index.server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type BetterAuthOptions, type BetterAuthPlugin, type Session, type User } from "better-auth";
|
|
2
2
|
import { type Dialect } from "kysely";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { DevProviderSignIn } from "./dev-sign-in/sign-in.ts";
|
|
4
5
|
import type { AuthProviderCredentials, AuthProviders } from "./providers/provider.ts";
|
|
5
6
|
import type { SessionLike } from "./types.ts";
|
|
6
7
|
/** `auth.users` columns beside better-auth's core fields. */
|
|
@@ -22,8 +23,16 @@ export interface ShellAuthApp<Fields extends AdditionalFields, Projected extends
|
|
|
22
23
|
providers?: AuthProviders;
|
|
23
24
|
/** Signed-in users link more provider accounts (`/link-social`). */
|
|
24
25
|
linkSocial?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Dev sign-in as any account of a provider (`devProviderSignIn`): `POST
|
|
28
|
+
* /api/auth/dev/sign-in` grants a session for any account id to any
|
|
29
|
+
* caller reaching the server; its trusted-origin `Origin` check is the
|
|
30
|
+
* one gate. Build it under the dev build flag alone
|
|
31
|
+
* (`import.meta.env.DEV`), which drops it from production bundles;
|
|
32
|
+
* `testProductionAuth` proves the production build serves no dev
|
|
33
|
+
* sign-in.
|
|
34
|
+
*/
|
|
35
|
+
devSignIn?: DevProviderSignIn;
|
|
27
36
|
/** Page OAuth failures redirect to, with `error` and `error_description`
|
|
28
37
|
* query params. */
|
|
29
38
|
errorURL: string;
|
|
@@ -300,15 +309,9 @@ export interface ShellSession<Fields extends AdditionalFields> {
|
|
|
300
309
|
user: User<SchemaOptions<Fields>["user"]>;
|
|
301
310
|
}
|
|
302
311
|
/** better-auth for `config` over the running scope's database: the `auth.*`
|
|
303
|
-
* schema, the session projection, the providers' plugins,
|
|
304
|
-
* `config.plugins`. */
|
|
312
|
+
* schema, the session projection, the providers' plugins, dev sign-in,
|
|
313
|
+
* then `config.plugins`. */
|
|
305
314
|
declare function buildAuth<Fields extends AdditionalFields, Projected extends SessionLike>(config: ShellAuthConfig<Fields, Projected>): import("better-auth").Auth<{
|
|
306
|
-
onAPIError: {
|
|
307
|
-
errorURL: string;
|
|
308
|
-
};
|
|
309
|
-
emailAndPassword?: {
|
|
310
|
-
enabled: true;
|
|
311
|
-
} | undefined;
|
|
312
315
|
baseURL: NonNullable<import("better-auth").BaseURLConfig | undefined>;
|
|
313
316
|
secret: string;
|
|
314
317
|
database: {
|
|
@@ -361,7 +364,29 @@ declare function buildAuth<Fields extends AdditionalFields, Projected extends Se
|
|
|
361
364
|
$Infer: {
|
|
362
365
|
Session: Projected;
|
|
363
366
|
};
|
|
364
|
-
}, ...BetterAuthPlugin
|
|
367
|
+
}, ...(BetterAuthPlugin | {
|
|
368
|
+
id: "dev-provider-sign-in";
|
|
369
|
+
endpoints: {
|
|
370
|
+
devProviderSignIn: import("better-auth").StrictEndpoint<"/dev/sign-in", {
|
|
371
|
+
method: "POST";
|
|
372
|
+
body: z.ZodObject<{
|
|
373
|
+
accountId: z.ZodType<string, unknown, z.core.$ZodTypeInternals<string, unknown>>;
|
|
374
|
+
}, z.core.$strip>;
|
|
375
|
+
requireRequest: true;
|
|
376
|
+
metadata: {
|
|
377
|
+
allowedMediaTypes: string[];
|
|
378
|
+
};
|
|
379
|
+
}, {
|
|
380
|
+
token: string;
|
|
381
|
+
user: {
|
|
382
|
+
id: string;
|
|
383
|
+
};
|
|
384
|
+
}>;
|
|
385
|
+
};
|
|
386
|
+
})[]];
|
|
387
|
+
onAPIError: {
|
|
388
|
+
errorURL: string;
|
|
389
|
+
};
|
|
365
390
|
appName: string;
|
|
366
391
|
advanced: {
|
|
367
392
|
disableOriginCheck: false;
|
package/dist/index.server.js
CHANGED
|
@@ -238,14 +238,14 @@ const scopedPostgres = {
|
|
|
238
238
|
createIntrospector: db => new PostgresIntrospector(db)
|
|
239
239
|
};
|
|
240
240
|
/** better-auth for `config` over the running scope's database: the `auth.*`
|
|
241
|
-
* schema, the session projection, the providers' plugins,
|
|
242
|
-
* `config.plugins`. */
|
|
241
|
+
* schema, the session projection, the providers' plugins, dev sign-in,
|
|
242
|
+
* then `config.plugins`. */
|
|
243
243
|
function buildAuth(config) {
|
|
244
244
|
const {
|
|
245
245
|
baseURL,
|
|
246
246
|
secret,
|
|
247
247
|
plugins,
|
|
248
|
-
|
|
248
|
+
devSignIn,
|
|
249
249
|
errorURL
|
|
250
250
|
} = config;
|
|
251
251
|
const schema = schemaOptions(config);
|
|
@@ -260,12 +260,7 @@ function buildAuth(config) {
|
|
|
260
260
|
transaction: true
|
|
261
261
|
},
|
|
262
262
|
socialProviders: providers.socialProviders,
|
|
263
|
-
plugins: [sessionProjection(config.projectSession), ...providers.plugins, ...(plugins ?? [])],
|
|
264
|
-
...(devEmailSignIn && {
|
|
265
|
-
emailAndPassword: {
|
|
266
|
-
enabled: true
|
|
267
|
-
}
|
|
268
|
-
}),
|
|
263
|
+
plugins: [sessionProjection(config.projectSession), ...providers.plugins, ...(devSignIn ? [devSignIn] : []), ...(plugins ?? [])],
|
|
269
264
|
onAPIError: {
|
|
270
265
|
errorURL
|
|
271
266
|
}
|