@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
package/dist/client.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** Browser half of shell-auth: the session store, auth calls and OAuth popups. */
|
|
2
|
+
import { log } from "@astralarium/repo-telemetry";
|
|
3
|
+
import { getGlobalBroadcastChannel } from "better-auth/client";
|
|
4
|
+
import { InferServerPlugin } from "better-auth/client/plugins";
|
|
5
|
+
import { createAuthClient } from "better-auth/react";
|
|
6
|
+
/**
|
|
7
|
+
* Posts Better Auth's session broadcast: every other tab refetches its
|
|
8
|
+
* session. Better Auth posts it itself on sign-out and user updates. The
|
|
9
|
+
* posting tab refetches on its own.
|
|
10
|
+
*/
|
|
11
|
+
function broadcastSession() {
|
|
12
|
+
getGlobalBroadcastChannel().post({
|
|
13
|
+
event: "session",
|
|
14
|
+
data: {
|
|
15
|
+
trigger: "getSession"
|
|
16
|
+
},
|
|
17
|
+
clientId: crypto.randomUUID()
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Opens `url`, a page starting a provider's OAuth redirect, in a popup
|
|
21
|
+
* centered over this window. */
|
|
22
|
+
export function openAuthPopup(url) {
|
|
23
|
+
const width = Math.min(400, screen.availWidth * 0.8, window.innerWidth);
|
|
24
|
+
const height = Math.min(600, screen.availHeight * 0.8, window.innerHeight);
|
|
25
|
+
const left = window.screenLeft + (window.innerWidth - width) / 2;
|
|
26
|
+
const top = window.screenTop + (window.innerHeight - height) / 2;
|
|
27
|
+
window.open(url, "_blank", `popup,width=${width},height=${height},left=${left},top=${top}`);
|
|
28
|
+
}
|
|
29
|
+
/** Ends an OAuth popup on its landing page: the other tabs, opener
|
|
30
|
+
* included, refresh their session; the popup closes. */
|
|
31
|
+
export function finishAuthPopup() {
|
|
32
|
+
broadcastSession();
|
|
33
|
+
window.close();
|
|
34
|
+
}
|
|
35
|
+
/** The error redirect's params in `search`; non-string values drop. */
|
|
36
|
+
export function parseAuthErrorSearch(search) {
|
|
37
|
+
const {
|
|
38
|
+
error,
|
|
39
|
+
error_description
|
|
40
|
+
} = search;
|
|
41
|
+
return {
|
|
42
|
+
...(typeof error === "string" ? {
|
|
43
|
+
error
|
|
44
|
+
} : {}),
|
|
45
|
+
...(typeof error_description === "string" ? {
|
|
46
|
+
error_description
|
|
47
|
+
} : {})
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** A response that started a session: Better Auth answers sign-in and
|
|
51
|
+
* sign-up with the new session's `token` and its `user`. */
|
|
52
|
+
function startsSession(data) {
|
|
53
|
+
return typeof data === "object" && data !== null && "token" in data && typeof data.token === "string" && "user" in data && typeof data.user === "object" && data.user !== null;
|
|
54
|
+
}
|
|
55
|
+
/** Broadcasts after a request starts a session. Better Auth's own
|
|
56
|
+
* listener refetches this tab; this one tells the others. */
|
|
57
|
+
const sessionBroadcast = {
|
|
58
|
+
id: "session-broadcast",
|
|
59
|
+
fetchPlugins: [{
|
|
60
|
+
id: "session-broadcast",
|
|
61
|
+
name: "session-broadcast",
|
|
62
|
+
hooks: {
|
|
63
|
+
onSuccess({
|
|
64
|
+
data
|
|
65
|
+
}) {
|
|
66
|
+
if (startsSession(data)) broadcastSession();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}]
|
|
70
|
+
};
|
|
71
|
+
/** Shared live session store and auth operations for `Auth`. */
|
|
72
|
+
export function createShellAuthClient() {
|
|
73
|
+
const authClient = createAuthClient({
|
|
74
|
+
plugins: [InferServerPlugin(), sessionBroadcast],
|
|
75
|
+
sessionOptions: {
|
|
76
|
+
refetchOnWindowFocus: true,
|
|
77
|
+
refetchWhenOffline: false
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
/** The client's live session. */
|
|
81
|
+
const sessionStore = authClient.$store.atoms.session;
|
|
82
|
+
/** Seeds the store with the page's dehydrated session. Better Auth types
|
|
83
|
+
* the seed with the base user merged in; the projection omits those
|
|
84
|
+
* fields. */
|
|
85
|
+
function hydrateSession(data) {
|
|
86
|
+
authClient.hydrateSession(data);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Reads the session fresh after a change made outside the auth client
|
|
90
|
+
* (a server function updating the user): past the cookie cache in this
|
|
91
|
+
* tab, then broadcast to the others.
|
|
92
|
+
*/
|
|
93
|
+
async function refreshSession() {
|
|
94
|
+
await sessionStore.get().refetch({
|
|
95
|
+
query: {
|
|
96
|
+
disableCookieCache: true
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
broadcastSession();
|
|
100
|
+
}
|
|
101
|
+
/** The signed-in user's id, `null` signed out; `undefined` while the
|
|
102
|
+
* store awaits its first read. */
|
|
103
|
+
function readUserId() {
|
|
104
|
+
const {
|
|
105
|
+
data,
|
|
106
|
+
isPending
|
|
107
|
+
} = sessionStore.get();
|
|
108
|
+
return isPending ? undefined : data?.user.id ?? null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Seeds the store with the page's `session` (`null` signed out), then
|
|
112
|
+
* calls `onChange` whenever the signed-in user changes: sign-in,
|
|
113
|
+
* sign-out, account switch, from this tab or another. An `undefined`
|
|
114
|
+
* session leaves the store unseeded; its first read sets the user. The
|
|
115
|
+
* seed and that first read fire nothing. Returns the unsubscribe.
|
|
116
|
+
*/
|
|
117
|
+
function watchSession(session, onChange) {
|
|
118
|
+
if (session !== undefined) hydrateSession(session);
|
|
119
|
+
let userId = session === undefined ? readUserId() : session?.user.id ?? null;
|
|
120
|
+
return sessionStore.listen(() => {
|
|
121
|
+
const next = readUserId();
|
|
122
|
+
if (next === undefined || next === userId) return;
|
|
123
|
+
const known = userId !== undefined;
|
|
124
|
+
userId = next;
|
|
125
|
+
if (known) onChange();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
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.
|
|
137
|
+
*/
|
|
138
|
+
function watchViewerRouter(router, queryClient, routeId) {
|
|
139
|
+
let stop = router.subscribe("onLoad", () => {
|
|
140
|
+
const match = router.state.matches.find(route => route.routeId === routeId);
|
|
141
|
+
if (!match) return;
|
|
142
|
+
stop();
|
|
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
|
+
});
|
|
158
|
+
});
|
|
159
|
+
return () => stop();
|
|
160
|
+
}
|
|
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
|
+
} = {}) {
|
|
168
|
+
const {
|
|
169
|
+
error
|
|
170
|
+
} = await authClient.$fetch(path, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
body,
|
|
173
|
+
...(form && {
|
|
174
|
+
headers: {
|
|
175
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
return error ?? undefined;
|
|
180
|
+
}
|
|
181
|
+
/** Starts `provider`'s OAuth redirect, landing on `callbackURL`.
|
|
182
|
+
* Resolves to the failure detail when the redirect fails to start. */
|
|
183
|
+
async function signInWithProvider(provider, callbackURL) {
|
|
184
|
+
try {
|
|
185
|
+
const error = await post("/sign-in/social", {
|
|
186
|
+
provider,
|
|
187
|
+
callbackURL
|
|
188
|
+
});
|
|
189
|
+
return error ? error.message ?? error.statusText : undefined;
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
authClient,
|
|
196
|
+
sessionStore,
|
|
197
|
+
refreshSession,
|
|
198
|
+
watchSession,
|
|
199
|
+
watchViewerRouter,
|
|
200
|
+
post,
|
|
201
|
+
signInWithProvider
|
|
202
|
+
};
|
|
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
|
+
}
|