@reatomized/convex 0.1.1 → 0.3.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 +60 -0
- package/dist/browser/reatom/auth_state.d.ts +59 -0
- package/dist/browser/reatom/auth_state.js +62 -0
- package/dist/browser/reatom/better_auth.d.ts +106 -0
- package/dist/browser/reatom/better_auth.js +110 -0
- package/dist/browser/reatom/convex_auth.d.ts +88 -0
- package/dist/browser/reatom/convex_auth.js +233 -0
- package/dist/browser/reatom/index.d.ts +2 -0
- package/dist/browser/reatom/index.js +1 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -55,6 +55,66 @@ gets its own connection. The socket opens on first use in a context.
|
|
|
55
55
|
There are no React hooks. Read the atoms through your Reatom view binding, such
|
|
56
56
|
as `@reatom/react` or `@reatom/jsx`.
|
|
57
57
|
|
|
58
|
+
## Auth
|
|
59
|
+
|
|
60
|
+
`reatomAuthState(convex, provider)` does what convex/react's
|
|
61
|
+
`ConvexProviderWithAuth` and `useConvexAuth` do. An auth library reports whether
|
|
62
|
+
it is loading and who is signed in, `reatomAuthState` sends that session's
|
|
63
|
+
token, and its `status` turns `authenticated` once the server accepts the token.
|
|
64
|
+
`status` is `loading`, `unauthenticated`, `authenticated` or `rejected`;
|
|
65
|
+
`isLoading`, `isAuthenticated` and `isRefreshing` are atoms too. The client
|
|
66
|
+
follows the provider while one of these atoms has a subscriber, so subscribe to
|
|
67
|
+
one at the root of the app.
|
|
68
|
+
|
|
69
|
+
Two entry points build the provider from an auth library:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// Convex Auth (@convex-dev/auth)
|
|
73
|
+
import { reatomConvexAuthProvider } from "@reatomized/convex/convex-auth";
|
|
74
|
+
|
|
75
|
+
const convexAuth = reatomConvexAuthProvider(convex);
|
|
76
|
+
const auth = reatomAuthState(convex, convexAuth);
|
|
77
|
+
await convexAuth.signIn("password", { email, password, flow: "signIn" });
|
|
78
|
+
await convexAuth.signIn("github"); // navigates to GitHub
|
|
79
|
+
await convexAuth.signOut();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`reatomConvexAuthProvider` implements the client side of `@convex-dev/auth`,
|
|
83
|
+
whose own client is a React provider. It uses the same storage keys, so sessions
|
|
84
|
+
carry over from `@convex-dev/auth/react`. On start it signs in with a `code`
|
|
85
|
+
from the URL, and it refreshes the token once across tabs.
|
|
86
|
+
`completeSignIn(code)` runs that exchange for an app that reads the URL itself;
|
|
87
|
+
its `error` atom holds a failed exchange either way.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// Better Auth with @convex-dev/better-auth
|
|
91
|
+
import { reatomBetterAuthProvider } from "@reatomized/convex/better-auth";
|
|
92
|
+
import {
|
|
93
|
+
convexClient,
|
|
94
|
+
crossDomainClient,
|
|
95
|
+
} from "@convex-dev/better-auth/client/plugins";
|
|
96
|
+
import { createAuthClient } from "better-auth/client";
|
|
97
|
+
|
|
98
|
+
const authClient = createAuthClient({
|
|
99
|
+
baseURL: import.meta.env.VITE_CONVEX_SITE_URL,
|
|
100
|
+
plugins: [convexClient(), crossDomainClient()],
|
|
101
|
+
});
|
|
102
|
+
const auth = reatomAuthState(convex, reatomBetterAuthProvider(authClient));
|
|
103
|
+
await authClient.signIn.email({ email, password });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Better Auth's client works without a framework, so you sign in through it;
|
|
107
|
+
`reatomBetterAuthProvider` follows its session store, fetches Convex tokens, and
|
|
108
|
+
trades the `ott` one-time token from a cross-domain OAuth redirect for the
|
|
109
|
+
session. Its `data` atom is the store's session and user, typed by your client,
|
|
110
|
+
and `completeSignIn(ott)` is the one-time token trade with an `error` atom.
|
|
111
|
+
Neither entry point adds dependencies.
|
|
112
|
+
|
|
113
|
+
For another library, pass any object with `isLoading` and `session` atoms and a
|
|
114
|
+
`fetchToken` function. `session` should change when the identity does, such as a
|
|
115
|
+
user or organization id. Pass an atom of providers to switch libraries at
|
|
116
|
+
runtime.
|
|
117
|
+
|
|
58
118
|
## Node
|
|
59
119
|
|
|
60
120
|
With `@reatom/core@1001.3.0` loaded, Node never exits on its own, so a script
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joins an auth library's session with the server's answer about its token,
|
|
3
|
+
* like convex/react's `ConvexProviderWithAuth`: the library says who is signed
|
|
4
|
+
* in, the client sends that session's token, and only a token the server
|
|
5
|
+
* accepted counts as authenticated.
|
|
6
|
+
*/
|
|
7
|
+
import { type AtomLike, type Computed } from "@reatom/core";
|
|
8
|
+
import type { AuthTokenFetcher } from "./auth.js";
|
|
9
|
+
import type { ReatomConvexClient } from "./client.js";
|
|
10
|
+
/**
|
|
11
|
+
* The part of an auth library the client needs. `@reatomized/convex/convex-auth`
|
|
12
|
+
* and `@reatomized/convex/better-auth` return one; for another library, build
|
|
13
|
+
* it from that library's session.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export type AuthProvider = {
|
|
18
|
+
/** The library is still restoring or completing its session. */
|
|
19
|
+
readonly isLoading: AtomLike<boolean, []>;
|
|
20
|
+
/**
|
|
21
|
+
* The signed-in session, `null` without one. Its value only needs to
|
|
22
|
+
* identify the session: when it changes, for example to another user or
|
|
23
|
+
* organization, the client sends a new token.
|
|
24
|
+
*/
|
|
25
|
+
readonly session: AtomLike<string | null, []>;
|
|
26
|
+
readonly fetchToken: AuthTokenFetcher;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `rejected` means the library has a session but the server refused its
|
|
30
|
+
* token, for example because convex/auth.config.ts doesn't list the issuer.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
export type AuthStatus = "loading" | "unauthenticated" | "authenticated" | "rejected";
|
|
35
|
+
/** @public */
|
|
36
|
+
export type ReatomAuthState = {
|
|
37
|
+
readonly status: Computed<AuthStatus>;
|
|
38
|
+
readonly isLoading: Computed<boolean>;
|
|
39
|
+
/** The server accepted the current session's token. */
|
|
40
|
+
readonly isAuthenticated: Computed<boolean>;
|
|
41
|
+
/** The server rejected an accepted token and a replacement is being fetched. */
|
|
42
|
+
readonly isRefreshing: Computed<boolean>;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Keeps the client's auth in step with `provider` while any of the returned
|
|
46
|
+
* atoms has a subscriber. Pass an atom of providers to switch libraries at
|
|
47
|
+
* runtime. Call `convex.setAuth` and `convex.clearAuth` only through this
|
|
48
|
+
* state once it is in use.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* const auth = reatomAuthState(convex, reatomConvexAuthProvider(convex));
|
|
52
|
+
* const notes = computed(() =>
|
|
53
|
+
* auth.isAuthenticated() ? convex.query(api.notes.mine, {}).data() : [],
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
export declare function reatomAuthState(convex: ReatomConvexClient, provider: AuthProvider | AtomLike<AuthProvider, []>, name?: string): ReatomAuthState;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joins an auth library's session with the server's answer about its token,
|
|
3
|
+
* like convex/react's `ConvexProviderWithAuth`: the library says who is signed
|
|
4
|
+
* in, the client sends that session's token, and only a token the server
|
|
5
|
+
* accepted counts as authenticated.
|
|
6
|
+
*/
|
|
7
|
+
import { atom, computed, effect, peek, withConnectHook, } from "@reatom/core";
|
|
8
|
+
/**
|
|
9
|
+
* Keeps the client's auth in step with `provider` while any of the returned
|
|
10
|
+
* atoms has a subscriber. Pass an atom of providers to switch libraries at
|
|
11
|
+
* runtime. Call `convex.setAuth` and `convex.clearAuth` only through this
|
|
12
|
+
* state once it is in use.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const auth = reatomAuthState(convex, reatomConvexAuthProvider(convex));
|
|
16
|
+
* const notes = computed(() =>
|
|
17
|
+
* auth.isAuthenticated() ? convex.query(api.notes.mine, {}).data() : [],
|
|
18
|
+
* );
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export function reatomAuthState(convex, provider, name = `${convex.name}.authState`) {
|
|
24
|
+
const current = typeof provider === "function" ? provider : () => provider;
|
|
25
|
+
// The provider and session whose token the client holds. Until it is the
|
|
26
|
+
// current one, the server's answer is about an earlier session.
|
|
27
|
+
const applied = atom(null, `${name}._applied`);
|
|
28
|
+
const sync = () => effect(() => {
|
|
29
|
+
const source = current();
|
|
30
|
+
const session = source.isLoading() ? null : source.session();
|
|
31
|
+
const last = peek(applied);
|
|
32
|
+
if (last?.provider === source && last.session === session)
|
|
33
|
+
return;
|
|
34
|
+
if (session !== null)
|
|
35
|
+
convex.setAuth(source.fetchToken);
|
|
36
|
+
else if (last)
|
|
37
|
+
convex.clearAuth();
|
|
38
|
+
applied.set(session === null ? null : { provider: source, session });
|
|
39
|
+
}, `${name}._sync`).unsubscribe;
|
|
40
|
+
const status = computed(() => {
|
|
41
|
+
const source = current();
|
|
42
|
+
if (source.isLoading())
|
|
43
|
+
return "loading";
|
|
44
|
+
const session = source.session();
|
|
45
|
+
if (session === null)
|
|
46
|
+
return "unauthenticated";
|
|
47
|
+
const last = applied();
|
|
48
|
+
if (last?.provider !== source || last.session !== session)
|
|
49
|
+
return "loading";
|
|
50
|
+
const accepted = convex.isAuthenticated();
|
|
51
|
+
if (accepted === null)
|
|
52
|
+
return "loading";
|
|
53
|
+
return accepted ? "authenticated" : "rejected";
|
|
54
|
+
}, `${name}.status`).extend(withConnectHook(sync));
|
|
55
|
+
const isAuthenticated = computed(() => status() === "authenticated", `${name}.isAuthenticated`);
|
|
56
|
+
return {
|
|
57
|
+
status,
|
|
58
|
+
isLoading: computed(() => status() === "loading", `${name}.isLoading`),
|
|
59
|
+
isAuthenticated,
|
|
60
|
+
isRefreshing: computed(() => isAuthenticated() && convex.isRefreshingAuth(), `${name}.isRefreshing`),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@reatomized/convex/better-auth` entry: a better-auth session for
|
|
3
|
+
* `reatomAuthState`, like `@convex-dev/better-auth/react`'s
|
|
4
|
+
* `ConvexBetterAuthProvider`. Sign in and out through the better-auth client
|
|
5
|
+
* itself; its session store tells this provider.
|
|
6
|
+
*/
|
|
7
|
+
import { type Action, type AsyncExt, type AtomLike, type Computed } from "@reatom/core";
|
|
8
|
+
import type { AuthProvider } from "./auth_state.js";
|
|
9
|
+
export type { AuthProvider } from "./auth_state.js";
|
|
10
|
+
type SessionState = {
|
|
11
|
+
data: {
|
|
12
|
+
session?: {
|
|
13
|
+
id: string;
|
|
14
|
+
} | null | undefined;
|
|
15
|
+
} | null;
|
|
16
|
+
isPending: boolean;
|
|
17
|
+
};
|
|
18
|
+
type SessionData<Client extends BetterAuthClient> = ReturnType<Client["useSession"]["get"]>["data"];
|
|
19
|
+
/**
|
|
20
|
+
* What the provider uses of a client from better-auth's `createAuthClient`
|
|
21
|
+
* (`better-auth/client`) with `@convex-dev/better-auth`'s `convexClient()`
|
|
22
|
+
* plugin, and `crossDomainClient()` when the Convex site is another origin.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export type BetterAuthClient = {
|
|
27
|
+
/** A nanostores store of the session. */
|
|
28
|
+
useSession: {
|
|
29
|
+
get(): SessionState;
|
|
30
|
+
subscribe(listener: (value: SessionState) => void): () => void;
|
|
31
|
+
};
|
|
32
|
+
convex: {
|
|
33
|
+
token(options: {
|
|
34
|
+
fetchOptions: {
|
|
35
|
+
throw: false;
|
|
36
|
+
};
|
|
37
|
+
}): Promise<{
|
|
38
|
+
data?: {
|
|
39
|
+
token?: string | null | undefined;
|
|
40
|
+
} | null | undefined;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
getSession(options?: {
|
|
44
|
+
fetchOptions?: {
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
};
|
|
47
|
+
}): Promise<unknown>;
|
|
48
|
+
crossDomain?: {
|
|
49
|
+
oneTimeToken: {
|
|
50
|
+
verify(args: {
|
|
51
|
+
token: string;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
data?: {
|
|
54
|
+
session?: {
|
|
55
|
+
token: string;
|
|
56
|
+
} | null | undefined;
|
|
57
|
+
} | null | undefined;
|
|
58
|
+
error?: {
|
|
59
|
+
message?: string | undefined;
|
|
60
|
+
} | null | undefined;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
updateSession?(): void;
|
|
65
|
+
};
|
|
66
|
+
/** @public */
|
|
67
|
+
export type BetterAuthOptions = {
|
|
68
|
+
/** A Convex token from server rendering, used until the session loads. */
|
|
69
|
+
initialToken?: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Whether an `ott` query param on start is a one-time token to sign in with;
|
|
72
|
+
* `true` by default. Only `crossDomainClient()` sends one.
|
|
73
|
+
*/
|
|
74
|
+
shouldHandleOneTimeToken?: boolean;
|
|
75
|
+
/** Drops `ott` from the address; `history.replaceState` by default. */
|
|
76
|
+
replaceURL?: (url: URL) => void | Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
/** @public */
|
|
79
|
+
export type ReatomBetterAuth<Data = SessionState["data"]> = AuthProvider & {
|
|
80
|
+
/** The session store's data: the session and user, `null` when signed out. */
|
|
81
|
+
readonly data: Computed<Data>;
|
|
82
|
+
/** The last Convex token, `null` without one. */
|
|
83
|
+
readonly token: AtomLike<string | null, []>;
|
|
84
|
+
/**
|
|
85
|
+
* Trades the one-time token a cross-domain OAuth redirect comes back with
|
|
86
|
+
* for the session. Runs on start for an `ott` in the URL; call it when the
|
|
87
|
+
* app takes the token from the URL itself. Its `error` holds a failed trade.
|
|
88
|
+
*/
|
|
89
|
+
readonly completeSignIn: Action<[oneTimeToken: string], Promise<void>> & AsyncExt<[oneTimeToken: string], void>;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* With `crossDomainClient()`, an OAuth sign-in comes back with a one-time
|
|
93
|
+
* token in the `ott` query param; the provider trades it for the session.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const authClient = createAuthClient({
|
|
97
|
+
* baseURL: import.meta.env.VITE_CONVEX_SITE_URL,
|
|
98
|
+
* plugins: [convexClient(), crossDomainClient()],
|
|
99
|
+
* });
|
|
100
|
+
* const auth = reatomAuthState(convex, reatomBetterAuthProvider(authClient));
|
|
101
|
+
* await authClient.signIn.email({ email, password });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
export declare function reatomBetterAuthProvider<Client extends BetterAuthClient>(authClient: Client, options?: BetterAuthOptions, name?: string): ReatomBetterAuth<SessionData<Client>>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@reatomized/convex/better-auth` entry: a better-auth session for
|
|
3
|
+
* `reatomAuthState`, like `@convex-dev/better-auth/react`'s
|
|
4
|
+
* `ConvexBetterAuthProvider`. Sign in and out through the better-auth client
|
|
5
|
+
* itself; its session store tells this provider.
|
|
6
|
+
*/
|
|
7
|
+
import { action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
|
|
8
|
+
/**
|
|
9
|
+
* With `crossDomainClient()`, an OAuth sign-in comes back with a one-time
|
|
10
|
+
* token in the `ott` query param; the provider trades it for the session.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const authClient = createAuthClient({
|
|
14
|
+
* baseURL: import.meta.env.VITE_CONVEX_SITE_URL,
|
|
15
|
+
* plugins: [convexClient(), crossDomainClient()],
|
|
16
|
+
* });
|
|
17
|
+
* const auth = reatomAuthState(convex, reatomBetterAuthProvider(authClient));
|
|
18
|
+
* await authClient.signIn.email({ email, password });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export function reatomBetterAuthProvider(authClient, options = {}, name = "betterAuth") {
|
|
24
|
+
const token = atom(options.initialToken ?? null, `${name}.token`);
|
|
25
|
+
// Boxed, so the atom holds the request rather than its result.
|
|
26
|
+
const pending = atom(null, `${name}._pending`);
|
|
27
|
+
const state = atom(() => authClient.useSession.get(), `${name}.sessionState`).extend(withConnectHook((target) => {
|
|
28
|
+
void start();
|
|
29
|
+
return authClient.useSession.subscribe(wrap((value) => {
|
|
30
|
+
const previous = peek(target).data?.session?.id;
|
|
31
|
+
target.set(value);
|
|
32
|
+
if (value.isPending)
|
|
33
|
+
return;
|
|
34
|
+
// The cached token belongs to the previous session, or to none.
|
|
35
|
+
const id = value.data?.session?.id;
|
|
36
|
+
if (!id || (previous && id !== previous))
|
|
37
|
+
token.set(null);
|
|
38
|
+
}));
|
|
39
|
+
}));
|
|
40
|
+
const completeSignIn = action(async (oneTimeToken) => {
|
|
41
|
+
// better-auth's client answers every property, so a missing plugin shows
|
|
42
|
+
// up as a failed request rather than an undefined `crossDomain`.
|
|
43
|
+
const result = await wrap(authClient.crossDomain.oneTimeToken.verify({ token: oneTimeToken }));
|
|
44
|
+
const session = result.data?.session;
|
|
45
|
+
if (!session)
|
|
46
|
+
throw new Error(result.error?.message ?? "Invalid one-time token");
|
|
47
|
+
await wrap(authClient.getSession({
|
|
48
|
+
fetchOptions: { headers: { Authorization: `Bearer ${session.token}` } },
|
|
49
|
+
}));
|
|
50
|
+
authClient.updateSession?.();
|
|
51
|
+
}, `${name}.completeSignIn`).extend(withAsync());
|
|
52
|
+
const started = atom(false, `${name}._started`);
|
|
53
|
+
const start = action(async () => {
|
|
54
|
+
if (peek(started))
|
|
55
|
+
return;
|
|
56
|
+
started.set(true);
|
|
57
|
+
if (options.shouldHandleOneTimeToken === false ||
|
|
58
|
+
typeof location === "undefined")
|
|
59
|
+
return;
|
|
60
|
+
const url = new URL(location.href);
|
|
61
|
+
const ott = url.searchParams.get("ott");
|
|
62
|
+
if (!ott)
|
|
63
|
+
return;
|
|
64
|
+
url.searchParams.delete("ott");
|
|
65
|
+
await wrap((options.replaceURL ??
|
|
66
|
+
((next) => history.replaceState({}, "", next)))(url));
|
|
67
|
+
try {
|
|
68
|
+
await wrap(completeSignIn(ott));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Kept in `completeSignIn.error()`.
|
|
72
|
+
}
|
|
73
|
+
}, `${name}._start`);
|
|
74
|
+
const fetchToken = action(async ({ forceRefreshToken }) => {
|
|
75
|
+
const cached = token();
|
|
76
|
+
if (cached && !forceRefreshToken)
|
|
77
|
+
return cached;
|
|
78
|
+
const inFlight = peek(pending);
|
|
79
|
+
if (inFlight && !forceRefreshToken)
|
|
80
|
+
return await wrap(inFlight.request);
|
|
81
|
+
const request = authClient.convex
|
|
82
|
+
.token({ fetchOptions: { throw: false } })
|
|
83
|
+
.then(({ data }) => data?.token || null, () => null);
|
|
84
|
+
const box = { request };
|
|
85
|
+
pending.set(box);
|
|
86
|
+
try {
|
|
87
|
+
const value = await wrap(request);
|
|
88
|
+
token.set(value);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
if (peek(pending) === box)
|
|
93
|
+
pending.set(null);
|
|
94
|
+
}
|
|
95
|
+
}, `${name}.fetchToken`);
|
|
96
|
+
return {
|
|
97
|
+
isLoading: computed(() => (state().isPending && token() === null) || !completeSignIn.ready(), `${name}.isLoading`),
|
|
98
|
+
data: computed(() => state().data, `${name}.data`),
|
|
99
|
+
// A token from server rendering stands in until the session loads.
|
|
100
|
+
session: computed(() => {
|
|
101
|
+
const id = state().data?.session?.id;
|
|
102
|
+
if (id)
|
|
103
|
+
return id;
|
|
104
|
+
return token() === null ? null : "initialToken";
|
|
105
|
+
}, `${name}.session`),
|
|
106
|
+
fetchToken,
|
|
107
|
+
token,
|
|
108
|
+
completeSignIn,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@reatomized/convex/convex-auth` entry: the client side of Convex Auth
|
|
3
|
+
* (`@convex-dev/auth`), whose own client ships only as a React provider.
|
|
4
|
+
*
|
|
5
|
+
* The `auth:signIn` action returns a JWT and a refresh token, trades a refresh
|
|
6
|
+
* token for a new pair, and for OAuth returns the provider's URL and a
|
|
7
|
+
* verifier; the backend sends the browser back with a `code` that, with the
|
|
8
|
+
* verifier, signs in. `auth:signOut` ends the server session. Storage keys and
|
|
9
|
+
* formats are `@convex-dev/auth/react`'s, so its sessions carry over.
|
|
10
|
+
*/
|
|
11
|
+
import { type Action, type AsyncExt, type AtomLike } from "@reatom/core";
|
|
12
|
+
import type { Value } from "convex/values";
|
|
13
|
+
import type { AuthProvider } from "./auth_state.js";
|
|
14
|
+
import type { ReatomConvexClient } from "./client.js";
|
|
15
|
+
export type { AuthProvider } from "./auth_state.js";
|
|
16
|
+
/**
|
|
17
|
+
* `localStorage`, `sessionStorage`, or an async store such as React Native's
|
|
18
|
+
* AsyncStorage.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export type ConvexAuthStorage = {
|
|
23
|
+
getItem(key: string): string | null | undefined | Promise<string | null | undefined>;
|
|
24
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
25
|
+
removeItem(key: string): void | Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
/** @public */
|
|
28
|
+
export type ConvexAuthOptions = {
|
|
29
|
+
/** `localStorage` by default. */
|
|
30
|
+
storage?: ConvexAuthStorage;
|
|
31
|
+
/** Separates the keys of several deployments; the client's URL by default. */
|
|
32
|
+
storageNamespace?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a `code` query param on start is a Convex Auth sign-in code;
|
|
35
|
+
* `true` by default. Turn it off for pages where another library uses `code`.
|
|
36
|
+
*/
|
|
37
|
+
shouldHandleCode?: boolean | (() => boolean);
|
|
38
|
+
/** Drops `code` from the address; `history.replaceState` by default. */
|
|
39
|
+
replaceURL?: (relativeUrl: string) => void | Promise<void>;
|
|
40
|
+
/** Sends the browser to an OAuth provider; `location.assign` by default. */
|
|
41
|
+
navigate?: (url: URL) => void | Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
/** @public */
|
|
44
|
+
export type ConvexAuthSignInResult = {
|
|
45
|
+
/** Tokens were issued and the session is starting. */
|
|
46
|
+
signingIn: boolean;
|
|
47
|
+
/** The OAuth provider's URL, after navigating to it. */
|
|
48
|
+
redirect?: URL;
|
|
49
|
+
};
|
|
50
|
+
/** @public */
|
|
51
|
+
export type ReatomConvexAuth = AuthProvider & {
|
|
52
|
+
/** The current JWT, `null` when signed out. */
|
|
53
|
+
readonly token: AtomLike<string | null, []>;
|
|
54
|
+
/** A refresh token is being exchanged. */
|
|
55
|
+
readonly isRefreshingToken: AtomLike<boolean, []>;
|
|
56
|
+
/**
|
|
57
|
+
* Calls `auth:signIn`. `provider` is a provider id from convex/auth.ts, such
|
|
58
|
+
* as `"password"` or `"github"`; `params` are its fields, such as `email`,
|
|
59
|
+
* `password` and `flow`.
|
|
60
|
+
*/
|
|
61
|
+
readonly signIn: Action<[
|
|
62
|
+
provider?: string | undefined,
|
|
63
|
+
params?: FormData | Record<string, Value> | undefined
|
|
64
|
+
], Promise<ConvexAuthSignInResult>>;
|
|
65
|
+
/**
|
|
66
|
+
* Signs in with the `code` the backend sends the browser back with after
|
|
67
|
+
* OAuth; `true` when tokens were issued. Runs on start for a `code` in the
|
|
68
|
+
* URL; call it when the app takes the code from the URL itself. Its `error`
|
|
69
|
+
* holds a failed exchange.
|
|
70
|
+
*/
|
|
71
|
+
readonly completeSignIn: Action<[code: string], Promise<boolean>> & AsyncExt<[code: string], boolean>;
|
|
72
|
+
/** Ends the session locally, even when `auth:signOut` fails. */
|
|
73
|
+
readonly signOut: Action<[], Promise<void>>;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A Convex Auth session for `reatomAuthState`. It restores the stored
|
|
77
|
+
* session, signs in with a `code` in the URL, follows other tabs, and runs one
|
|
78
|
+
* refresh token exchange at a time across tabs.
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* const convexAuth = reatomConvexAuthProvider(convex);
|
|
82
|
+
* const auth = reatomAuthState(convex, convexAuth);
|
|
83
|
+
* await convexAuth.signIn("password", { email, password, flow: "signIn" });
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
export declare function reatomConvexAuthProvider(convex: ReatomConvexClient, options?: ConvexAuthOptions, name?: string): ReatomConvexAuth;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@reatomized/convex/convex-auth` entry: the client side of Convex Auth
|
|
3
|
+
* (`@convex-dev/auth`), whose own client ships only as a React provider.
|
|
4
|
+
*
|
|
5
|
+
* The `auth:signIn` action returns a JWT and a refresh token, trades a refresh
|
|
6
|
+
* token for a new pair, and for OAuth returns the provider's URL and a
|
|
7
|
+
* verifier; the backend sends the browser back with a `code` that, with the
|
|
8
|
+
* verifier, signs in. `auth:signOut` ends the server session. Storage keys and
|
|
9
|
+
* formats are `@convex-dev/auth/react`'s, so its sessions carry over.
|
|
10
|
+
*/
|
|
11
|
+
import { action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
|
|
12
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
13
|
+
import { makeFunctionReference } from "convex/server";
|
|
14
|
+
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
15
|
+
const JWT_KEY = "__convexAuthJWT";
|
|
16
|
+
const REFRESH_TOKEN_KEY = "__convexAuthRefreshToken";
|
|
17
|
+
const VERIFIER_KEY = "__convexAuthOAuthVerifier";
|
|
18
|
+
// Retry delays for a code or refresh token exchange that hits a network error.
|
|
19
|
+
const RETRY_BACKOFF = [500, 2000];
|
|
20
|
+
const RETRY_JITTER = 100;
|
|
21
|
+
const signInRef = makeFunctionReference("auth:signIn");
|
|
22
|
+
const signOutRef = makeFunctionReference("auth:signOut");
|
|
23
|
+
/**
|
|
24
|
+
* A Convex Auth session for `reatomAuthState`. It restores the stored
|
|
25
|
+
* session, signs in with a `code` in the URL, follows other tabs, and runs one
|
|
26
|
+
* refresh token exchange at a time across tabs.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const convexAuth = reatomConvexAuthProvider(convex);
|
|
30
|
+
* const auth = reatomAuthState(convex, convexAuth);
|
|
31
|
+
* await convexAuth.signIn("password", { email, password, flow: "signIn" });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.name}.convexAuth`) {
|
|
37
|
+
const namespace = (options.storageNamespace ?? convex.url).replace(/[^a-zA-Z0-9]/g, "");
|
|
38
|
+
const key = (base) => `${base}_${namespace}`;
|
|
39
|
+
const storage = () => {
|
|
40
|
+
const value = options.storage ??
|
|
41
|
+
(typeof localStorage === "undefined" ? undefined : localStorage);
|
|
42
|
+
if (!value)
|
|
43
|
+
throw new Error("`localStorage` is not available, pass `storage` to reatomConvexAuthProvider");
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
const read = (base) => wrap(storage().getItem(key(base)));
|
|
47
|
+
const write = (base, value) => wrap(value === null
|
|
48
|
+
? storage().removeItem(key(base))
|
|
49
|
+
: storage().setItem(key(base), value));
|
|
50
|
+
const token = atom(null, `${name}.token`).extend(withConnectHook(() => {
|
|
51
|
+
void start();
|
|
52
|
+
const stopFollowing = followStorage();
|
|
53
|
+
const stopWarning = warnOnUnload();
|
|
54
|
+
return () => {
|
|
55
|
+
stopFollowing?.();
|
|
56
|
+
stopWarning?.();
|
|
57
|
+
};
|
|
58
|
+
}));
|
|
59
|
+
const restoring = atom(true, `${name}._restoring`).extend(withConnectHook(() => {
|
|
60
|
+
void start();
|
|
61
|
+
}));
|
|
62
|
+
const isRefreshingToken = atom(false, `${name}.isRefreshingToken`);
|
|
63
|
+
const started = atom(false, `${name}._started`);
|
|
64
|
+
// `sub` is `userId|sessionId`, so a refreshed token keeps the session.
|
|
65
|
+
const session = computed(() => {
|
|
66
|
+
const value = token();
|
|
67
|
+
if (value === null)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const { sub } = jwtDecode(value);
|
|
71
|
+
return sub ?? value;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
}, `${name}.session`);
|
|
77
|
+
const apply = (value) => {
|
|
78
|
+
token.set(value);
|
|
79
|
+
restoring.set(false);
|
|
80
|
+
};
|
|
81
|
+
const store = async (tokens) => {
|
|
82
|
+
await write(JWT_KEY, tokens?.token ?? null);
|
|
83
|
+
await write(REFRESH_TOKEN_KEY, tokens?.refreshToken ?? null);
|
|
84
|
+
apply(tokens?.token ?? null);
|
|
85
|
+
};
|
|
86
|
+
// Over HTTP: the client asks for a fresh token while its socket is paused
|
|
87
|
+
// or stopped for auth.
|
|
88
|
+
const exchange = async (args) => {
|
|
89
|
+
for (let retry = 0;; retry++) {
|
|
90
|
+
try {
|
|
91
|
+
return await wrap(new ConvexHttpClient(convex.url, { logger: false }).action(signInRef, args));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// fetch rejects with a TypeError when the request doesn't go through.
|
|
95
|
+
if (!(error instanceof TypeError) || retry >= RETRY_BACKOFF.length)
|
|
96
|
+
throw error;
|
|
97
|
+
const delay = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
|
|
98
|
+
await wrap(new Promise((resolve) => setTimeout(resolve, delay)));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const signIn = action(async (provider, params) => {
|
|
103
|
+
const fields = params instanceof FormData
|
|
104
|
+
? Object.fromEntries(Array.from(params.entries(), ([field, value]) => [
|
|
105
|
+
field,
|
|
106
|
+
typeof value === "string" ? value : value.name,
|
|
107
|
+
]))
|
|
108
|
+
: (params ?? {});
|
|
109
|
+
const verifier = (await read(VERIFIER_KEY)) ?? undefined;
|
|
110
|
+
await write(VERIFIER_KEY, null);
|
|
111
|
+
const result = await wrap(convex.action(signInRef, { provider, params: fields, verifier }));
|
|
112
|
+
if (result.redirect !== undefined) {
|
|
113
|
+
const url = new URL(result.redirect);
|
|
114
|
+
await write(VERIFIER_KEY, result.verifier ?? null);
|
|
115
|
+
await wrap((options.navigate ?? ((target) => location.assign(target)))(url));
|
|
116
|
+
return { signingIn: false, redirect: url };
|
|
117
|
+
}
|
|
118
|
+
if (result.tokens !== undefined) {
|
|
119
|
+
await wrap(store(result.tokens));
|
|
120
|
+
return { signingIn: result.tokens !== null };
|
|
121
|
+
}
|
|
122
|
+
return { signingIn: false };
|
|
123
|
+
}, `${name}.signIn`);
|
|
124
|
+
const completeSignIn = action(async (code) => {
|
|
125
|
+
const { signingIn } = await wrap(signIn(undefined, { code }));
|
|
126
|
+
return signingIn;
|
|
127
|
+
}, `${name}.completeSignIn`).extend(withAsync());
|
|
128
|
+
const isLoading = computed(() => restoring() || !completeSignIn.ready(), `${name}.isLoading`);
|
|
129
|
+
const signOut = action(async () => {
|
|
130
|
+
try {
|
|
131
|
+
await wrap(convex.action(signOutRef, {}));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Usually the session has already ended on the server.
|
|
135
|
+
}
|
|
136
|
+
await wrap(store(null));
|
|
137
|
+
}, `${name}.signOut`);
|
|
138
|
+
const refresh = async (before) => {
|
|
139
|
+
// Another tab refreshed while this one waited for the lock.
|
|
140
|
+
if (peek(token) !== before)
|
|
141
|
+
return peek(token);
|
|
142
|
+
const refreshToken = (await read(REFRESH_TOKEN_KEY)) ?? null;
|
|
143
|
+
if (refreshToken === null)
|
|
144
|
+
return null;
|
|
145
|
+
isRefreshingToken.set(true);
|
|
146
|
+
try {
|
|
147
|
+
const { tokens } = await wrap(exchange({ refreshToken }));
|
|
148
|
+
await wrap(store(tokens ?? null));
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
isRefreshingToken.set(false);
|
|
152
|
+
}
|
|
153
|
+
return peek(token);
|
|
154
|
+
};
|
|
155
|
+
const fetchToken = action(async ({ forceRefreshToken }) => {
|
|
156
|
+
const before = token();
|
|
157
|
+
if (!forceRefreshToken)
|
|
158
|
+
return before;
|
|
159
|
+
return await wrap(exclusive(key(REFRESH_TOKEN_KEY), wrap(() => refresh(before))));
|
|
160
|
+
}, `${name}.fetchToken`);
|
|
161
|
+
const start = action(async () => {
|
|
162
|
+
if (peek(started))
|
|
163
|
+
return;
|
|
164
|
+
started.set(true);
|
|
165
|
+
const handleCode = typeof options.shouldHandleCode === "function"
|
|
166
|
+
? options.shouldHandleCode()
|
|
167
|
+
: (options.shouldHandleCode ?? true);
|
|
168
|
+
const url = typeof location === "undefined" ? undefined : new URL(location.href);
|
|
169
|
+
const code = url?.searchParams.get("code");
|
|
170
|
+
if (url && code && handleCode) {
|
|
171
|
+
url.searchParams.delete("code");
|
|
172
|
+
await wrap((options.replaceURL ??
|
|
173
|
+
((relative) => history.replaceState({}, "", relative)))(url.pathname + url.search + url.hash));
|
|
174
|
+
try {
|
|
175
|
+
await wrap(completeSignIn(code));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Kept in `completeSignIn.error()`.
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
restoring.set(false);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
apply((await read(JWT_KEY)) ?? null);
|
|
186
|
+
}, `${name}._start`);
|
|
187
|
+
// Follows the token other tabs or frames write to the same storage.
|
|
188
|
+
const followStorage = () => {
|
|
189
|
+
if (typeof window === "undefined")
|
|
190
|
+
return undefined;
|
|
191
|
+
const listener = wrap((event) => {
|
|
192
|
+
if (event.storageArea !== storage() || event.key !== key(JWT_KEY))
|
|
193
|
+
return;
|
|
194
|
+
// Each key comes as its own event; the refresh token stays in storage.
|
|
195
|
+
apply(event.newValue);
|
|
196
|
+
});
|
|
197
|
+
window.addEventListener("storage", listener);
|
|
198
|
+
return () => window.removeEventListener("storage", listener);
|
|
199
|
+
};
|
|
200
|
+
// Leaving during a refresh token exchange can lose the new pair.
|
|
201
|
+
const warnOnUnload = () => {
|
|
202
|
+
if (typeof window === "undefined")
|
|
203
|
+
return undefined;
|
|
204
|
+
const listener = wrap((event) => {
|
|
205
|
+
if (!peek(isRefreshingToken))
|
|
206
|
+
return;
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
event.returnValue = true;
|
|
209
|
+
});
|
|
210
|
+
window.addEventListener("beforeunload", listener);
|
|
211
|
+
return () => window.removeEventListener("beforeunload", listener);
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
isLoading,
|
|
215
|
+
session,
|
|
216
|
+
fetchToken,
|
|
217
|
+
token,
|
|
218
|
+
isRefreshingToken,
|
|
219
|
+
signIn,
|
|
220
|
+
completeSignIn,
|
|
221
|
+
signOut,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const queues = new Map();
|
|
225
|
+
/** Runs `callback` alone among the tabs, or among callers in this one. */
|
|
226
|
+
function exclusive(lock, callback) {
|
|
227
|
+
if (typeof navigator !== "undefined" && navigator.locks)
|
|
228
|
+
return navigator.locks.request(lock, callback);
|
|
229
|
+
const previous = queues.get(lock) ?? Promise.resolve();
|
|
230
|
+
const next = previous.then(callback, callback);
|
|
231
|
+
queues.set(lock, next.catch(() => { }));
|
|
232
|
+
return next;
|
|
233
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { reatomConvexClient } from "./client.js";
|
|
2
2
|
export type { BaseConvexClientOptions as ReatomConvexClientOptions, ConnectionState, MutationOptions, QueryModification, QueryStatus, ReatomConvexClient, ReatomQuery, ReatomQueryOptions, Transition, } from "./client.js";
|
|
3
3
|
export type { AuthTokenFetcher } from "./auth.js";
|
|
4
|
+
export { reatomAuthState } from "./auth_state.js";
|
|
5
|
+
export type { AuthProvider, AuthStatus, ReatomAuthState, } from "./auth_state.js";
|
|
4
6
|
export type { PaginatedQueryState, ReatomPaginatedQuery } from "./paginated.js";
|
|
5
7
|
export type { SocketState } from "./socket.js";
|
|
6
8
|
export type { OptimisticLocalStore } from "convex/browser";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reatomized/convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A Convex sync client with its state in Reatom atoms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"convex",
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
"types": "./dist/browser/reatom/index.d.ts",
|
|
26
26
|
"default": "./dist/browser/reatom/index.js"
|
|
27
27
|
},
|
|
28
|
+
"./convex-auth": {
|
|
29
|
+
"types": "./dist/browser/reatom/convex_auth.d.ts",
|
|
30
|
+
"default": "./dist/browser/reatom/convex_auth.js"
|
|
31
|
+
},
|
|
32
|
+
"./better-auth": {
|
|
33
|
+
"types": "./dist/browser/reatom/better_auth.d.ts",
|
|
34
|
+
"default": "./dist/browser/reatom/better_auth.js"
|
|
35
|
+
},
|
|
28
36
|
"./package.json": "./package.json"
|
|
29
37
|
},
|
|
30
38
|
"files": [
|