@gusnips/react 0.9.27 → 0.9.29
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 +71 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +145 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +99 -0
- package/dist/oauth.js.map +1 -0
- package/dist/supabase-session.d.ts +25 -3
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +44 -15
- package/dist/supabase-session.js.map +1 -1
- package/package.json +5 -1
- package/src/index.ts +1 -0
- package/src/oauth.test.ts +257 -0
- package/src/oauth.ts +217 -0
- package/src/supabase-session.test.ts +46 -5
- package/src/supabase-session.ts +50 -15
package/README.md
CHANGED
|
@@ -108,7 +108,22 @@ Two rules come with it:
|
|
|
108
108
|
has finished the first call: a moment later, or up to 22 seconds on auth-js 2.106.
|
|
109
109
|
|
|
110
110
|
`error` is `null` when auth ended the session on the server too. Otherwise the person is signed
|
|
111
|
-
out on this device and may still be
|
|
111
|
+
out on this device, and that session may still be live on the server.
|
|
112
|
+
|
|
113
|
+
It signs out this device only. auth-js's own default ends every session the person has, so signing
|
|
114
|
+
out of a laptop also signed them out of their phone, and a tab left open there failed its next
|
|
115
|
+
refresh with "Invalid Refresh Token". To end the others on purpose, give account settings a button
|
|
116
|
+
that calls `signOutOtherDevices`. It keeps this device signed in, so it can be pressed again when
|
|
117
|
+
it fails:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { signOutOtherDevices } from "@gusnips/react/supabase";
|
|
121
|
+
|
|
122
|
+
const { error } = await signOutOtherDevices(supabase.auth);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`error` is `null` once auth has ended the other sessions. Like the sign-out, it waits on auth for
|
|
126
|
+
3 seconds at most.
|
|
112
127
|
|
|
113
128
|
Pass the same object to `createSupabaseSessionAdapter(supabase.auth, authStorage)` and the API
|
|
114
129
|
client's sign-out goes through the helper too.
|
|
@@ -674,6 +689,60 @@ silently become model messages. It returns `false` for ordinary text. The hook n
|
|
|
674
689
|
message, clears a draft or changes attached files; those stay with the app. It also has no timer
|
|
675
690
|
or scheduler: offer a repeat command only when your app has real scheduling behind it.
|
|
676
691
|
|
|
692
|
+
## Signing an agent in
|
|
693
|
+
|
|
694
|
+
When Claude or ChatGPT connects to your API, it sends the person to your consent page. When one of
|
|
695
|
+
your scripts signs in on a machine with no browser, it prints a short code, and the person types
|
|
696
|
+
it on your device page. `@gusnips/server/oauth` is the server half. These two hooks are the
|
|
697
|
+
logic for the two pages. The pages themselves are yours.
|
|
698
|
+
|
|
699
|
+
```tsx
|
|
700
|
+
import { useConsentRequest } from "@gusnips/react/oauth";
|
|
701
|
+
|
|
702
|
+
const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
703
|
+
|
|
704
|
+
if (consent.state.status === "ready")
|
|
705
|
+
return (
|
|
706
|
+
<Button onClick={() => consent.approve(workspaceId)} loading={consent.leaving}>
|
|
707
|
+
Allow {consent.state.view.clientName}
|
|
708
|
+
</Button>
|
|
709
|
+
);
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
`api.oauth` is two calls to your own API: `describe(query)` and `approve(query, choice)`. They
|
|
713
|
+
reach the server's `describe` and `approve` through routes behind your sign-in. `state.status` is
|
|
714
|
+
`loading`, `refused`, `failed` or `ready`:
|
|
715
|
+
|
|
716
|
+
- **`refused`** means the request is not one to answer: an unknown app, or a return address it
|
|
717
|
+
never registered. Show `refusal.message` (or your own words for `refusal.reason`) and a link
|
|
718
|
+
home. Never a link back to the app, because sending the browser anywhere on a bad request is
|
|
719
|
+
how an open redirect is built.
|
|
720
|
+
- **`failed`** means the request didn't arrive or answer. Show what failed and `state.retry`.
|
|
721
|
+
- **`ready`** has the app's name and `redirectHost`, where the browser goes after the yes. Show
|
|
722
|
+
both: anyone can register any name, and the host is the part a stranger can't pick.
|
|
723
|
+
|
|
724
|
+
`leaving` stays `true` from the press until the browser has left, so the button never comes back
|
|
725
|
+
for a second press. Approve sends the browser on with `window.location.assign`, which also opens
|
|
726
|
+
an app's own scheme such as `cursor://`.
|
|
727
|
+
|
|
728
|
+
```tsx
|
|
729
|
+
import { useDeviceApproval } from "@gusnips/react/oauth";
|
|
730
|
+
|
|
731
|
+
const device = useDeviceApproval(api.device, useLocation().search);
|
|
732
|
+
|
|
733
|
+
<input value={device.code} onChange={(event) => device.setCode(event.target.value)} />;
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
The link the script prints carries `?code=`, so the field usually starts full. The field shows
|
|
737
|
+
what was typed as `WDJB-MJHT`, however it was typed. `invalidLetter` turns on as soon as a letter
|
|
738
|
+
no code uses (a vowel) is typed, so you can say so before all eight are in. `state.status` adds
|
|
739
|
+
three to the consent page's four: `typing` until the code is complete, then `approved` or
|
|
740
|
+
`denied`. When it is `ready`, show `view.userCode` and say: go on only if you started this
|
|
741
|
+
sign-in yourself, just now, and this is the code on your screen. Somebody can run the real script
|
|
742
|
+
and send a stranger the link, and that sentence is the only guard against it. Once decided, the
|
|
743
|
+
page stays decided: it never asks the server again, because the server no longer knows the code,
|
|
744
|
+
and asking on the next focus would tell someone who just said yes that their code is wrong.
|
|
745
|
+
|
|
677
746
|
## Also here
|
|
678
747
|
|
|
679
748
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
@@ -755,6 +824,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
|
|
|
755
824
|
| `@gusnips/react/contract` | two prerender names | nothing |
|
|
756
825
|
| `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
|
|
757
826
|
| `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
|
|
827
|
+
| `@gusnips/react/oauth` | `useConsentRequest`, `useDeviceApproval` | @tanstack/react-query |
|
|
758
828
|
|
|
759
829
|
The rule behind that table: **a peer marked optional must not be reachable from the main entry
|
|
760
830
|
point.** An optional peer the barrel imports anyway is not optional: it is a required one whose
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// @gusnips/react/supabase the session adapter → @supabase/supabase-js
|
|
16
16
|
// @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
|
|
17
17
|
// serializes it and must not load the rest
|
|
18
|
+
// @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
|
|
18
19
|
//
|
|
19
20
|
// `react-dom` is the one the second migration added, and it is the sharpest case yet: that
|
|
20
21
|
// adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,0FAA0F;AAC1F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
|
|
3
|
+
* person to connect, and the device page, where a script's short code is typed and approved.
|
|
4
|
+
* `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
|
|
5
|
+
*
|
|
6
|
+
* Each hook takes your two API calls, so your client, your session and your envelope stay yours,
|
|
7
|
+
* and returns one state to draw. What they settle once:
|
|
8
|
+
*
|
|
9
|
+
* - A refusal (an unknown app, a return address it never registered) is a STATE, never a
|
|
10
|
+
* redirect: sending the browser anywhere on a bad request is how an open redirect is built.
|
|
11
|
+
* - Approve stays busy from the press until the browser has left. The page is going away, so the
|
|
12
|
+
* button must never come back and invite a second press.
|
|
13
|
+
* - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
|
|
14
|
+
* page stays decided. Asked again, the server no longer knows the code, and a page that asked on
|
|
15
|
+
* the next focus would tell someone who just approved that their code is wrong.
|
|
16
|
+
*/
|
|
17
|
+
export type OAuthRefusalReason = "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
|
|
18
|
+
/** What the server sends instead of a view. Word your own page for `reason`. */
|
|
19
|
+
export interface OAuthRefusal {
|
|
20
|
+
ok: false;
|
|
21
|
+
reason: OAuthRefusalReason;
|
|
22
|
+
/** An English fallback a person can read. */
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ConsentView {
|
|
26
|
+
ok: true;
|
|
27
|
+
/** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
|
|
28
|
+
clientName: string;
|
|
29
|
+
/** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
|
|
30
|
+
redirectHost: string;
|
|
31
|
+
/** The key's name on your keys page. */
|
|
32
|
+
keyName: string;
|
|
33
|
+
/** Where "Cancel" goes: back to the app, which is told the person said no. */
|
|
34
|
+
cancelUrl: string;
|
|
35
|
+
}
|
|
36
|
+
export interface DeviceView {
|
|
37
|
+
ok: true;
|
|
38
|
+
clientName: string;
|
|
39
|
+
/** As the script printed it: `WDJB-MJHT`. */
|
|
40
|
+
userCode: string;
|
|
41
|
+
keyName: string;
|
|
42
|
+
/** ISO time: JSON carries the server's `Date` as a string. */
|
|
43
|
+
expiresAt: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ConsentApi<Choice> {
|
|
46
|
+
/** Your API's call to the server's `describe`, with the query string as it came. */
|
|
47
|
+
describe(query: string): Promise<ConsentView | OAuthRefusal>;
|
|
48
|
+
/** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
|
|
49
|
+
approve(query: string, choice: Choice): Promise<{
|
|
50
|
+
ok: true;
|
|
51
|
+
redirectTo: string;
|
|
52
|
+
} | OAuthRefusal>;
|
|
53
|
+
}
|
|
54
|
+
export type ConsentState = {
|
|
55
|
+
status: "loading";
|
|
56
|
+
}
|
|
57
|
+
/** Show `refusal` and a way home. Never a link back to the app. */
|
|
58
|
+
| {
|
|
59
|
+
status: "refused";
|
|
60
|
+
refusal: OAuthRefusal;
|
|
61
|
+
}
|
|
62
|
+
/** The request failed to arrive or answer. Show what failed, and `retry`. */
|
|
63
|
+
| {
|
|
64
|
+
status: "failed";
|
|
65
|
+
error: unknown;
|
|
66
|
+
retry: () => void;
|
|
67
|
+
} | {
|
|
68
|
+
status: "ready";
|
|
69
|
+
view: ConsentView;
|
|
70
|
+
};
|
|
71
|
+
export interface ConsentRequest<Choice> {
|
|
72
|
+
state: ConsentState;
|
|
73
|
+
approve(choice: Choice): void;
|
|
74
|
+
/** From the press until the browser has left. Keep the button busy the whole time. */
|
|
75
|
+
leaving: boolean;
|
|
76
|
+
/** Approve did not answer. Show it beside the button; pressing again retries. */
|
|
77
|
+
approveError: unknown;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
81
|
+
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
82
|
+
*/
|
|
83
|
+
export declare function useConsentRequest<Choice = void>(api: ConsentApi<Choice>, search: string): ConsentRequest<Choice>;
|
|
84
|
+
export interface DeviceApi {
|
|
85
|
+
/** Your API's call to the server's `describeDevice`. */
|
|
86
|
+
describe(code: string): Promise<DeviceView | OAuthRefusal>;
|
|
87
|
+
/** Your API's call to `decideDevice`. */
|
|
88
|
+
decide(code: string, decision: "approve" | "deny"): Promise<{
|
|
89
|
+
ok: true;
|
|
90
|
+
} | OAuthRefusal>;
|
|
91
|
+
}
|
|
92
|
+
export type DeviceState =
|
|
93
|
+
/** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
|
|
94
|
+
{
|
|
95
|
+
status: "typing";
|
|
96
|
+
} | {
|
|
97
|
+
status: "loading";
|
|
98
|
+
}
|
|
99
|
+
/** Unknown, expired or already decided: ask them to start again from the app. */
|
|
100
|
+
| {
|
|
101
|
+
status: "refused";
|
|
102
|
+
refusal: OAuthRefusal;
|
|
103
|
+
} | {
|
|
104
|
+
status: "failed";
|
|
105
|
+
error: unknown;
|
|
106
|
+
retry: () => void;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
|
|
110
|
+
* this is the code on your screen. Somebody can run the real script and send a stranger the
|
|
111
|
+
* link, and that sentence is the only guard against it (RFC 8628 §5.4).
|
|
112
|
+
*/
|
|
113
|
+
| {
|
|
114
|
+
status: "ready";
|
|
115
|
+
view: DeviceView;
|
|
116
|
+
}
|
|
117
|
+
/** Done. Tell them to go back to the app, which carries on by itself. */
|
|
118
|
+
| {
|
|
119
|
+
status: "approved";
|
|
120
|
+
} | {
|
|
121
|
+
status: "denied";
|
|
122
|
+
};
|
|
123
|
+
export interface DeviceApproval {
|
|
124
|
+
state: DeviceState;
|
|
125
|
+
/** What the field shows, formatted as they type: `WDJB-MJ`. */
|
|
126
|
+
code: string;
|
|
127
|
+
setCode(input: string): void;
|
|
128
|
+
/** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
|
|
129
|
+
invalidLetter: boolean;
|
|
130
|
+
approve(): void;
|
|
131
|
+
deny(): void;
|
|
132
|
+
deciding: boolean;
|
|
133
|
+
/** The decision did not answer. Show it beside the buttons; pressing again retries. */
|
|
134
|
+
decideError: unknown;
|
|
135
|
+
}
|
|
136
|
+
/** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
|
|
137
|
+
export declare function formatUserCode(input: string): string;
|
|
138
|
+
/**
|
|
139
|
+
* const device = useDeviceApproval(api.device, useLocation().search);
|
|
140
|
+
* <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
|
|
141
|
+
*
|
|
142
|
+
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
143
|
+
*/
|
|
144
|
+
export declare function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval;
|
|
145
|
+
//# sourceMappingURL=oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
5
|
+
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
6
|
+
*/
|
|
7
|
+
export function useConsentRequest(api, search) {
|
|
8
|
+
const query = search.replace(/^\?/, "");
|
|
9
|
+
const request = useQuery({
|
|
10
|
+
queryKey: ["@gusnips/react/oauth", "consent", query],
|
|
11
|
+
queryFn: () => api.describe(query),
|
|
12
|
+
});
|
|
13
|
+
const approval = useMutation({
|
|
14
|
+
mutationFn: (choice) => api.approve(query, choice),
|
|
15
|
+
onSuccess: (answer) => {
|
|
16
|
+
if (answer.ok)
|
|
17
|
+
window.location.assign(answer.redirectTo);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
// A refusal from approve (the request went stale between the two calls) outranks the view.
|
|
21
|
+
const answer = request.data;
|
|
22
|
+
const refusal = approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
|
|
23
|
+
let state;
|
|
24
|
+
if (refusal)
|
|
25
|
+
state = { status: "refused", refusal };
|
|
26
|
+
else if (answer?.ok === true)
|
|
27
|
+
state = { status: "ready", view: answer };
|
|
28
|
+
else if (request.isError)
|
|
29
|
+
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
30
|
+
else
|
|
31
|
+
state = { status: "loading" };
|
|
32
|
+
return {
|
|
33
|
+
state,
|
|
34
|
+
approve: (choice) => approval.mutate(choice),
|
|
35
|
+
leaving: approval.isPending || approval.data?.ok === true,
|
|
36
|
+
approveError: approval.error,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The server's alphabet: no vowels, so a code never spells a word. */
|
|
40
|
+
const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
41
|
+
const LENGTH = 8;
|
|
42
|
+
/** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
|
|
43
|
+
export function formatUserCode(input) {
|
|
44
|
+
const letters = input
|
|
45
|
+
.toUpperCase()
|
|
46
|
+
.replace(/[^A-Z]/g, "")
|
|
47
|
+
.slice(0, LENGTH);
|
|
48
|
+
return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* const device = useDeviceApproval(api.device, useLocation().search);
|
|
52
|
+
* <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
|
|
53
|
+
*
|
|
54
|
+
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
55
|
+
*/
|
|
56
|
+
export function useDeviceApproval(api, search) {
|
|
57
|
+
const [code, setTyped] = useState(() => formatUserCode(new URLSearchParams(search).get("code") ?? ""));
|
|
58
|
+
const letters = code.replace("-", "");
|
|
59
|
+
const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
|
|
60
|
+
const complete = letters.length === LENGTH && !invalidLetter;
|
|
61
|
+
const decision = useMutation({
|
|
62
|
+
mutationFn: (choice) => api.decide(letters, choice),
|
|
63
|
+
});
|
|
64
|
+
const decided = decision.data?.ok === true ? decision.variables : undefined;
|
|
65
|
+
const request = useQuery({
|
|
66
|
+
queryKey: ["@gusnips/react/oauth", "device", letters],
|
|
67
|
+
queryFn: () => api.describe(letters),
|
|
68
|
+
enabled: complete && decided === undefined,
|
|
69
|
+
});
|
|
70
|
+
const answer = request.data;
|
|
71
|
+
const refusal = decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
|
|
72
|
+
let state;
|
|
73
|
+
if (decided !== undefined)
|
|
74
|
+
state = { status: decided === "approve" ? "approved" : "denied" };
|
|
75
|
+
else if (!complete)
|
|
76
|
+
state = { status: "typing" };
|
|
77
|
+
else if (refusal)
|
|
78
|
+
state = { status: "refused", refusal };
|
|
79
|
+
else if (answer?.ok === true)
|
|
80
|
+
state = { status: "ready", view: answer };
|
|
81
|
+
else if (request.isError)
|
|
82
|
+
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
83
|
+
else
|
|
84
|
+
state = { status: "loading" };
|
|
85
|
+
return {
|
|
86
|
+
state,
|
|
87
|
+
code,
|
|
88
|
+
setCode: (input) => {
|
|
89
|
+
decision.reset();
|
|
90
|
+
setTyped(formatUserCode(input));
|
|
91
|
+
},
|
|
92
|
+
invalidLetter,
|
|
93
|
+
approve: () => decision.mutate("approve"),
|
|
94
|
+
deny: () => decision.mutate("deny"),
|
|
95
|
+
deciding: decision.isPending,
|
|
96
|
+
decideError: decision.error,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
|
|
@@ -93,11 +93,17 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
|
|
|
93
93
|
* it touches storage. This happens at every version, and it is the common case: a laptop that
|
|
94
94
|
* wakes up offline with a tab still open.
|
|
95
95
|
* - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
|
|
96
|
-
* before it removes anything.
|
|
96
|
+
* before it removes anything.
|
|
97
97
|
*
|
|
98
98
|
* Either way a reload reads the session and signs the person back in. On a shared computer, that
|
|
99
99
|
* person is whoever sits down next. A screen that says "signed out" at that point is wrong.
|
|
100
100
|
*
|
|
101
|
+
* It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
|
|
102
|
+
* cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
|
|
103
|
+
* every session the person has. Signing out of a laptop then signed them out of their phone, and a
|
|
104
|
+
* tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
|
|
105
|
+
* Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
|
|
106
|
+
*
|
|
101
107
|
* Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
|
|
102
108
|
* listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
|
|
103
109
|
* this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
|
|
@@ -110,7 +116,7 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
|
|
|
110
116
|
*
|
|
111
117
|
* It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
|
|
112
118
|
* server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
|
|
113
|
-
* The session may still be live on the server
|
|
119
|
+
* The session may still be live on the server. `SIGNED_OUT` arrives once
|
|
114
120
|
* auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
|
|
115
121
|
* later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
|
|
116
122
|
* of waiting for the event.
|
|
@@ -118,6 +124,21 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
|
|
|
118
124
|
export declare function signOutEvenOffline(auth: Pick<SupabaseSessionAuth, "signOut">, { storage, storageKey }: SupabaseSessionStorage): Promise<{
|
|
119
125
|
error: unknown;
|
|
120
126
|
}>;
|
|
127
|
+
/**
|
|
128
|
+
* Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
|
|
129
|
+
* of other devices" button in account settings: a phone that was lost, a computer left signed in.
|
|
130
|
+
*
|
|
131
|
+
* It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
|
|
132
|
+
* it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
|
|
133
|
+
* so the person is still signed in and can press the button again.
|
|
134
|
+
*
|
|
135
|
+
* Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
|
|
136
|
+
* offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
|
|
137
|
+
* prove the other sessions are still live: auth may end them after this gave up.
|
|
138
|
+
*/
|
|
139
|
+
export declare function signOutOtherDevices(auth: Pick<SupabaseSessionAuth, "signOut">): Promise<{
|
|
140
|
+
error: unknown;
|
|
141
|
+
}>;
|
|
121
142
|
/**
|
|
122
143
|
* Connects a Supabase auth client to {@link SessionAdapter}.
|
|
123
144
|
*
|
|
@@ -130,7 +151,8 @@ export declare function signOutEvenOffline(auth: Pick<SupabaseSessionAuth, "sign
|
|
|
130
151
|
* rejection means. It still rejects when auth did not end the session, but by then the session
|
|
131
152
|
* has already left this device. And a sign-out auth has not answered within
|
|
132
153
|
* {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
|
|
133
|
-
* `auth.signOut()` and rejects on that call's error
|
|
154
|
+
* `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
|
|
155
|
+
* device's session only: a token one device's API refused says nothing about the others.
|
|
134
156
|
*
|
|
135
157
|
* `stored` is optional here but not at a sign-out button. The API client signs out only after
|
|
136
158
|
* auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,OAAO,EAGZ,KAAK,gBAAgB,EACtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAuB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE3E,UAAU,qBAAqB;IAC7B,IAAI,EAAE;QAAE,OAAO,EAAE;YAAE,YAAY,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,CAAC;CACpD;AAED,8FAA8F;AAC9F,MAAM,WAAW,mBAAmB;IAClC,UAAU,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,cAAc,IAAI,OAAO,CAAC,qBAAqB,GAAG;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACzD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,gBAAgB,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;CACpB;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASpD;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,sBAAsB,CAE/E;AA2BD
|
|
1
|
+
{"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"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"}
|
package/dist/supabase-session.js
CHANGED
|
@@ -116,11 +116,17 @@ function localStorageOrMemory() {
|
|
|
116
116
|
* it touches storage. This happens at every version, and it is the common case: a laptop that
|
|
117
117
|
* wakes up offline with a tab still open.
|
|
118
118
|
* - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
|
|
119
|
-
* before it removes anything.
|
|
119
|
+
* before it removes anything.
|
|
120
120
|
*
|
|
121
121
|
* Either way a reload reads the session and signs the person back in. On a shared computer, that
|
|
122
122
|
* person is whoever sits down next. A screen that says "signed out" at that point is wrong.
|
|
123
123
|
*
|
|
124
|
+
* It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
|
|
125
|
+
* cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
|
|
126
|
+
* every session the person has. Signing out of a laptop then signed them out of their phone, and a
|
|
127
|
+
* tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
|
|
128
|
+
* Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
|
|
129
|
+
*
|
|
124
130
|
* Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
|
|
125
131
|
* listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
|
|
126
132
|
* this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
|
|
@@ -133,23 +139,13 @@ function localStorageOrMemory() {
|
|
|
133
139
|
*
|
|
134
140
|
* It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
|
|
135
141
|
* server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
|
|
136
|
-
* The session may still be live on the server
|
|
142
|
+
* The session may still be live on the server. `SIGNED_OUT` arrives once
|
|
137
143
|
* auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
|
|
138
144
|
* later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
|
|
139
145
|
* of waiting for the event.
|
|
140
146
|
*/
|
|
141
147
|
export async function signOutEvenOffline(auth, { storage, storageKey }) {
|
|
142
|
-
|
|
143
|
-
const deadline = new Promise((resolve) => {
|
|
144
|
-
timer = setTimeout(() => resolve({
|
|
145
|
-
error: new Error(`Auth did not answer the sign-out within ${SIGN_OUT_TIMEOUT_MS} ms`),
|
|
146
|
-
}), SIGN_OUT_TIMEOUT_MS);
|
|
147
|
-
});
|
|
148
|
-
const { error } = await Promise.race([
|
|
149
|
-
auth.signOut().catch((thrown) => ({ error: thrown })),
|
|
150
|
-
deadline,
|
|
151
|
-
]);
|
|
152
|
-
clearTimeout(timer);
|
|
148
|
+
const { error } = await withinSignOutDeadline(auth.signOut({ scope: "local" }));
|
|
153
149
|
if (error == null)
|
|
154
150
|
return { error: null };
|
|
155
151
|
// From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
|
|
@@ -162,6 +158,36 @@ export async function signOutEvenOffline(auth, { storage, storageKey }) {
|
|
|
162
158
|
void auth.signOut({ scope: "local" }).catch(() => { });
|
|
163
159
|
return { error };
|
|
164
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
|
|
163
|
+
* of other devices" button in account settings: a phone that was lost, a computer left signed in.
|
|
164
|
+
*
|
|
165
|
+
* It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
|
|
166
|
+
* it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
|
|
167
|
+
* so the person is still signed in and can press the button again.
|
|
168
|
+
*
|
|
169
|
+
* Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
|
|
170
|
+
* offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
|
|
171
|
+
* prove the other sessions are still live: auth may end them after this gave up.
|
|
172
|
+
*/
|
|
173
|
+
export function signOutOtherDevices(auth) {
|
|
174
|
+
return withinSignOutDeadline(auth.signOut({ scope: "others" }));
|
|
175
|
+
}
|
|
176
|
+
/** `call`'s result, or the deadline as its error once {@link SIGN_OUT_TIMEOUT_MS} has passed. */
|
|
177
|
+
async function withinSignOutDeadline(call) {
|
|
178
|
+
let timer;
|
|
179
|
+
const deadline = new Promise((resolve) => {
|
|
180
|
+
timer = setTimeout(() => resolve({
|
|
181
|
+
error: new Error(`Auth did not answer the sign-out within ${SIGN_OUT_TIMEOUT_MS} ms`),
|
|
182
|
+
}), SIGN_OUT_TIMEOUT_MS);
|
|
183
|
+
});
|
|
184
|
+
const result = await Promise.race([
|
|
185
|
+
call.catch((thrown) => ({ error: thrown })),
|
|
186
|
+
deadline,
|
|
187
|
+
]);
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
165
191
|
/**
|
|
166
192
|
* Connects a Supabase auth client to {@link SessionAdapter}.
|
|
167
193
|
*
|
|
@@ -174,7 +200,8 @@ export async function signOutEvenOffline(auth, { storage, storageKey }) {
|
|
|
174
200
|
* rejection means. It still rejects when auth did not end the session, but by then the session
|
|
175
201
|
* has already left this device. And a sign-out auth has not answered within
|
|
176
202
|
* {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
|
|
177
|
-
* `auth.signOut()` and rejects on that call's error
|
|
203
|
+
* `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
|
|
204
|
+
* device's session only: a token one device's API refused says nothing about the others.
|
|
178
205
|
*
|
|
179
206
|
* `stored` is optional here but not at a sign-out button. The API client signs out only after
|
|
180
207
|
* auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
|
|
@@ -199,7 +226,9 @@ export function createSupabaseSessionAdapter(auth, stored) {
|
|
|
199
226
|
}
|
|
200
227
|
},
|
|
201
228
|
signOut: async () => {
|
|
202
|
-
const { error } = stored
|
|
229
|
+
const { error } = stored
|
|
230
|
+
? await signOutEvenOffline(auth, stored)
|
|
231
|
+
: await auth.signOut({ scope: "local" });
|
|
203
232
|
if (error)
|
|
204
233
|
throw error;
|
|
205
234
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,yBAAyB,GAK1B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,mBAAmB,EAAuB,MAAM,iBAAiB,CAAC;AAiC3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,4FAA4F;IAC5F,6EAA6E;IAC7E,8FAA8F;IAC9F,8FAA8F;IAC9F,gFAAgF;IAChF,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,yBAAyB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC;AACxE,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,0FAA0F;IAC1F,uFAAuF;IACvF,kFAAkF;IAClF,8EAA8E;IAC9E,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7D,8FAA8F;IAC9F,8FAA8F;IAC9F,OAAO,KAAK,CAAC,IAAI,KAAK,2BAA2B,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,WAAmB;IACpD,OAAO,MAAM,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,WAAmB;IACrD,OAAO,EAAE,OAAO,EAAE,oBAAoB,EAAE,EAAE,UAAU,EAAE,kBAAkB,CAAC,WAAW,CAAC,EAAE,CAAC;AAC1F,CAAC;AAED,SAAS,oBAAoB;IAC3B,0FAA0F;IAC1F,4FAA4F;IAC5F,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACrE,IAAI,CAAC;YACH,MAAM,EAAE,YAAY,EAAE,GAAG,UAAU,CAAC;YACpC,YAAY,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC9C,YAAY,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAC;YAC5C,OAAO,YAAY,CAAC;QACtB,CAAC;QAAC,MAAM,CAAC;YACP,0BAA0B;QAC5B,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI;QACxC,OAAO,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;YACtB,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACxB,CAAC;QACD,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;YAClB,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACpB,CAAC;KACF,CAAC;AACJ,CAAC;AAED
|
|
1
|
+
{"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.29",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"./commands": {
|
|
46
46
|
"types": "./dist/commands.d.ts",
|
|
47
47
|
"default": "./dist/commands.js"
|
|
48
|
+
},
|
|
49
|
+
"./oauth": {
|
|
50
|
+
"types": "./dist/oauth.d.ts",
|
|
51
|
+
"default": "./dist/oauth.js"
|
|
48
52
|
}
|
|
49
53
|
},
|
|
50
54
|
"types": "./dist/index.d.ts",
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// @gusnips/react/supabase the session adapter → @supabase/supabase-js
|
|
16
16
|
// @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
|
|
17
17
|
// serializes it and must not load the rest
|
|
18
|
+
// @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
|
|
18
19
|
//
|
|
19
20
|
// `react-dom` is the one the second migration added, and it is the sharpest case yet: that
|
|
20
21
|
// adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatUserCode,
|
|
8
|
+
useConsentRequest,
|
|
9
|
+
useDeviceApproval,
|
|
10
|
+
type ConsentApi,
|
|
11
|
+
type ConsentView,
|
|
12
|
+
type DeviceApi,
|
|
13
|
+
type DeviceView,
|
|
14
|
+
type OAuthRefusal,
|
|
15
|
+
} from "./oauth.ts";
|
|
16
|
+
|
|
17
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
18
|
+
|
|
19
|
+
const roots = new Set<Root>();
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const root of roots) act(() => root.unmount());
|
|
22
|
+
roots.clear();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Renders `hook` under a fresh query client and keeps its latest answer in `current`. */
|
|
27
|
+
function mount<T>(hook: () => T) {
|
|
28
|
+
const seen: T[] = [];
|
|
29
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
30
|
+
function Probe() {
|
|
31
|
+
seen.push(hook());
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const root = createRoot(document.createElement("div"));
|
|
35
|
+
roots.add(root);
|
|
36
|
+
act(() => root.render(createElement(QueryClientProvider, { client }, createElement(Probe))));
|
|
37
|
+
return {
|
|
38
|
+
client,
|
|
39
|
+
get current(): T {
|
|
40
|
+
const last = seen.at(-1);
|
|
41
|
+
if (last === undefined) throw new Error("The hook never rendered");
|
|
42
|
+
return last;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lets pending promises and the renders they cause finish. react-query hands its updates to a
|
|
49
|
+
* scheduler that runs on a later task, so one tick is not always enough.
|
|
50
|
+
*/
|
|
51
|
+
const settle = () =>
|
|
52
|
+
act(async () => {
|
|
53
|
+
for (let tick = 0; tick < 5; tick++) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const VIEW: ConsentView = {
|
|
57
|
+
ok: true,
|
|
58
|
+
clientName: "Claude",
|
|
59
|
+
redirectHost: "claude.ai",
|
|
60
|
+
keyName: "Claude (claude.ai)",
|
|
61
|
+
cancelUrl: "https://claude.ai/cb?error=access_denied",
|
|
62
|
+
};
|
|
63
|
+
const REFUSAL: OAuthRefusal = {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: "redirect-mismatch",
|
|
66
|
+
message: "This app's sign-in link is not valid.",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe("useConsentRequest", () => {
|
|
70
|
+
it("loads the request, approves with the page's choice, and leaves for the redirect", async () => {
|
|
71
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
72
|
+
let release!: () => void;
|
|
73
|
+
const api: ConsentApi<string> = {
|
|
74
|
+
describe: vi.fn(async () => VIEW),
|
|
75
|
+
approve: vi.fn(
|
|
76
|
+
() =>
|
|
77
|
+
new Promise<{ ok: true; redirectTo: string }>((resolve) => {
|
|
78
|
+
release = () => resolve({ ok: true, redirectTo: "https://claude.ai/cb?code=c1" });
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
const page = mount(() => useConsentRequest(api, "?client_id=x&state=s"));
|
|
83
|
+
expect(page.current.state).toEqual({ status: "loading" });
|
|
84
|
+
await settle();
|
|
85
|
+
expect(page.current.state).toEqual({ status: "ready", view: VIEW });
|
|
86
|
+
expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s");
|
|
87
|
+
|
|
88
|
+
act(() => page.current.approve("ws-1"));
|
|
89
|
+
await settle();
|
|
90
|
+
expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1");
|
|
91
|
+
expect(page.current.leaving).toBe(true);
|
|
92
|
+
|
|
93
|
+
release();
|
|
94
|
+
await settle();
|
|
95
|
+
expect(assign).toHaveBeenCalledWith("https://claude.ai/cb?code=c1");
|
|
96
|
+
// The page is going away: the button must not come back for a second press.
|
|
97
|
+
expect(page.current.leaving).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows a refusal as a state and never redirects on it", async () => {
|
|
101
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
102
|
+
const page = mount(() =>
|
|
103
|
+
useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x"),
|
|
104
|
+
);
|
|
105
|
+
await settle();
|
|
106
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
|
|
107
|
+
expect(assign).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("turns a refusal from approve into the refused state, and stays put", async () => {
|
|
111
|
+
const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
|
|
112
|
+
const page = mount(() =>
|
|
113
|
+
useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, ""),
|
|
114
|
+
);
|
|
115
|
+
await settle();
|
|
116
|
+
act(() => page.current.approve());
|
|
117
|
+
await settle();
|
|
118
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
|
|
119
|
+
expect(page.current.leaving).toBe(false);
|
|
120
|
+
expect(assign).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("offers a retry when the request failed to answer, and recovers", async () => {
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const page = mount(() =>
|
|
126
|
+
useConsentRequest(
|
|
127
|
+
{
|
|
128
|
+
describe: async () => {
|
|
129
|
+
calls += 1;
|
|
130
|
+
if (calls === 1) throw new TypeError("Failed to fetch");
|
|
131
|
+
return VIEW;
|
|
132
|
+
},
|
|
133
|
+
approve: vi.fn(),
|
|
134
|
+
},
|
|
135
|
+
"?a=1",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
await settle();
|
|
139
|
+
const failed = page.current.state;
|
|
140
|
+
if (failed.status !== "failed") throw new Error(`expected failed, got ${failed.status}`);
|
|
141
|
+
expect(failed.error).toBeInstanceOf(TypeError);
|
|
142
|
+
act(() => failed.retry());
|
|
143
|
+
await settle();
|
|
144
|
+
expect(page.current.state).toEqual({ status: "ready", view: VIEW });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps an approve that did not answer beside the button, ready to press again", async () => {
|
|
148
|
+
const page = mount(() =>
|
|
149
|
+
useConsentRequest(
|
|
150
|
+
{
|
|
151
|
+
describe: async () => VIEW,
|
|
152
|
+
approve: async () => {
|
|
153
|
+
throw new TypeError("Failed to fetch");
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"",
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
await settle();
|
|
160
|
+
act(() => page.current.approve());
|
|
161
|
+
await settle();
|
|
162
|
+
expect(page.current.approveError).toBeInstanceOf(TypeError);
|
|
163
|
+
expect(page.current.leaving).toBe(false);
|
|
164
|
+
expect(page.current.state.status).toBe("ready");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const DEVICE: DeviceView = {
|
|
169
|
+
ok: true,
|
|
170
|
+
clientName: "Acme CLI",
|
|
171
|
+
userCode: "WDJB-MJHT",
|
|
172
|
+
keyName: "Acme CLI (device sign-in)",
|
|
173
|
+
expiresAt: "2026-10-16T12:10:00.000Z",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
describe("useDeviceApproval", () => {
|
|
177
|
+
function deviceApi(overrides: Partial<DeviceApi> = {}) {
|
|
178
|
+
return {
|
|
179
|
+
describe: vi.fn<DeviceApi["describe"]>(overrides.describe ?? (async () => DEVICE)),
|
|
180
|
+
decide: vi.fn<DeviceApi["decide"]>(overrides.decide ?? (async () => ({ ok: true }))),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
|
|
185
|
+
const api = deviceApi();
|
|
186
|
+
const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
|
|
187
|
+
expect(page.current.code).toBe("WDJB-MJHT");
|
|
188
|
+
await settle();
|
|
189
|
+
expect(page.current.state).toEqual({ status: "ready", view: DEVICE });
|
|
190
|
+
expect(api.describe).toHaveBeenCalledWith("WDJBMJHT");
|
|
191
|
+
|
|
192
|
+
act(() => page.current.approve());
|
|
193
|
+
await settle();
|
|
194
|
+
expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve");
|
|
195
|
+
expect(page.current.state).toEqual({ status: "approved" });
|
|
196
|
+
|
|
197
|
+
// A refetch on focus would now hear "unknown code". It must not be asked.
|
|
198
|
+
api.describe.mockResolvedValue({ ok: false, reason: "unknown-code", message: "gone" });
|
|
199
|
+
await act(async () => page.client.invalidateQueries());
|
|
200
|
+
await settle();
|
|
201
|
+
expect(page.current.state).toEqual({ status: "approved" });
|
|
202
|
+
expect(api.describe).toHaveBeenCalledTimes(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("waits for all eight letters before asking, and flags a vowel at once", async () => {
|
|
206
|
+
const api = deviceApi();
|
|
207
|
+
const page = mount(() => useDeviceApproval(api, ""));
|
|
208
|
+
expect(page.current.state).toEqual({ status: "typing" });
|
|
209
|
+
act(() => page.current.setCode("wdjb m"));
|
|
210
|
+
expect(page.current.code).toBe("WDJB-M");
|
|
211
|
+
act(() => page.current.setCode("WDJA"));
|
|
212
|
+
expect(page.current.invalidLetter).toBe(true);
|
|
213
|
+
act(() => page.current.setCode("WDJB MJHTX"));
|
|
214
|
+
expect(page.current.code).toBe("WDJB-MJHT");
|
|
215
|
+
await settle();
|
|
216
|
+
expect(api.describe).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(page.current.state.status).toBe("ready");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("says no, and shows a code the server does not know as refused", async () => {
|
|
221
|
+
const said = mount(() => useDeviceApproval(deviceApi(), "?code=WDJBMJHT"));
|
|
222
|
+
await settle();
|
|
223
|
+
act(() => said.current.deny());
|
|
224
|
+
await settle();
|
|
225
|
+
expect(said.current.state).toEqual({ status: "denied" });
|
|
226
|
+
|
|
227
|
+
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
228
|
+
const gone = mount(() =>
|
|
229
|
+
useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT"),
|
|
230
|
+
);
|
|
231
|
+
await settle();
|
|
232
|
+
expect(gone.current.state).toEqual({ status: "refused", refusal: unknown });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("shows a decision the server refused, and lets a new code start over", async () => {
|
|
236
|
+
const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
|
|
237
|
+
const api = deviceApi({ decide: async () => unknown });
|
|
238
|
+
const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
|
|
239
|
+
await settle();
|
|
240
|
+
act(() => page.current.approve());
|
|
241
|
+
await settle();
|
|
242
|
+
expect(page.current.state).toEqual({ status: "refused", refusal: unknown });
|
|
243
|
+
act(() => page.current.setCode("BCDF GHJK"));
|
|
244
|
+
await settle();
|
|
245
|
+
expect(page.current.state.status).toBe("ready");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("formatUserCode", () => {
|
|
250
|
+
it("writes what the field shows", () => {
|
|
251
|
+
expect(formatUserCode("")).toBe("");
|
|
252
|
+
expect(formatUserCode("wdj")).toBe("WDJ");
|
|
253
|
+
expect(formatUserCode("wdjb")).toBe("WDJB");
|
|
254
|
+
expect(formatUserCode("wdjbm")).toBe("WDJB-M");
|
|
255
|
+
expect(formatUserCode(" w-d j b m j h t z ")).toBe("WDJB-MJHT");
|
|
256
|
+
});
|
|
257
|
+
});
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
|
|
6
|
+
* person to connect, and the device page, where a script's short code is typed and approved.
|
|
7
|
+
* `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
|
|
8
|
+
*
|
|
9
|
+
* Each hook takes your two API calls, so your client, your session and your envelope stay yours,
|
|
10
|
+
* and returns one state to draw. What they settle once:
|
|
11
|
+
*
|
|
12
|
+
* - A refusal (an unknown app, a return address it never registered) is a STATE, never a
|
|
13
|
+
* redirect: sending the browser anywhere on a bad request is how an open redirect is built.
|
|
14
|
+
* - Approve stays busy from the press until the browser has left. The page is going away, so the
|
|
15
|
+
* button must never come back and invite a second press.
|
|
16
|
+
* - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
|
|
17
|
+
* page stays decided. Asked again, the server no longer knows the code, and a page that asked on
|
|
18
|
+
* the next focus would tell someone who just approved that their code is wrong.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type OAuthRefusalReason =
|
|
22
|
+
"unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
|
|
23
|
+
|
|
24
|
+
/** What the server sends instead of a view. Word your own page for `reason`. */
|
|
25
|
+
export interface OAuthRefusal {
|
|
26
|
+
ok: false;
|
|
27
|
+
reason: OAuthRefusalReason;
|
|
28
|
+
/** An English fallback a person can read. */
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConsentView {
|
|
33
|
+
ok: true;
|
|
34
|
+
/** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
|
|
35
|
+
clientName: string;
|
|
36
|
+
/** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
|
|
37
|
+
redirectHost: string;
|
|
38
|
+
/** The key's name on your keys page. */
|
|
39
|
+
keyName: string;
|
|
40
|
+
/** Where "Cancel" goes: back to the app, which is told the person said no. */
|
|
41
|
+
cancelUrl: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DeviceView {
|
|
45
|
+
ok: true;
|
|
46
|
+
clientName: string;
|
|
47
|
+
/** As the script printed it: `WDJB-MJHT`. */
|
|
48
|
+
userCode: string;
|
|
49
|
+
keyName: string;
|
|
50
|
+
/** ISO time: JSON carries the server's `Date` as a string. */
|
|
51
|
+
expiresAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ConsentApi<Choice> {
|
|
55
|
+
/** Your API's call to the server's `describe`, with the query string as it came. */
|
|
56
|
+
describe(query: string): Promise<ConsentView | OAuthRefusal>;
|
|
57
|
+
/** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
|
|
58
|
+
approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ConsentState =
|
|
62
|
+
| { status: "loading" }
|
|
63
|
+
/** Show `refusal` and a way home. Never a link back to the app. */
|
|
64
|
+
| { status: "refused"; refusal: OAuthRefusal }
|
|
65
|
+
/** The request failed to arrive or answer. Show what failed, and `retry`. */
|
|
66
|
+
| { status: "failed"; error: unknown; retry: () => void }
|
|
67
|
+
| { status: "ready"; view: ConsentView };
|
|
68
|
+
|
|
69
|
+
export interface ConsentRequest<Choice> {
|
|
70
|
+
state: ConsentState;
|
|
71
|
+
approve(choice: Choice): void;
|
|
72
|
+
/** From the press until the browser has left. Keep the button busy the whole time. */
|
|
73
|
+
leaving: boolean;
|
|
74
|
+
/** Approve did not answer. Show it beside the button; pressing again retries. */
|
|
75
|
+
approveError: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* const consent = useConsentRequest(api.oauth, useLocation().search);
|
|
80
|
+
* if (consent.state.status === "ready") ... consent.approve(workspaceId)
|
|
81
|
+
*/
|
|
82
|
+
export function useConsentRequest<Choice = void>(
|
|
83
|
+
api: ConsentApi<Choice>,
|
|
84
|
+
search: string,
|
|
85
|
+
): ConsentRequest<Choice> {
|
|
86
|
+
const query = search.replace(/^\?/, "");
|
|
87
|
+
const request = useQuery({
|
|
88
|
+
queryKey: ["@gusnips/react/oauth", "consent", query],
|
|
89
|
+
queryFn: () => api.describe(query),
|
|
90
|
+
});
|
|
91
|
+
const approval = useMutation({
|
|
92
|
+
mutationFn: (choice: Choice) => api.approve(query, choice),
|
|
93
|
+
onSuccess: (answer) => {
|
|
94
|
+
if (answer.ok) window.location.assign(answer.redirectTo);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A refusal from approve (the request went stale between the two calls) outranks the view.
|
|
99
|
+
const answer = request.data;
|
|
100
|
+
const refusal =
|
|
101
|
+
approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
|
|
102
|
+
let state: ConsentState;
|
|
103
|
+
if (refusal) state = { status: "refused", refusal };
|
|
104
|
+
else if (answer?.ok === true) state = { status: "ready", view: answer };
|
|
105
|
+
else if (request.isError)
|
|
106
|
+
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
107
|
+
else state = { status: "loading" };
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
state,
|
|
111
|
+
approve: (choice) => approval.mutate(choice),
|
|
112
|
+
leaving: approval.isPending || approval.data?.ok === true,
|
|
113
|
+
approveError: approval.error,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface DeviceApi {
|
|
118
|
+
/** Your API's call to the server's `describeDevice`. */
|
|
119
|
+
describe(code: string): Promise<DeviceView | OAuthRefusal>;
|
|
120
|
+
/** Your API's call to `decideDevice`. */
|
|
121
|
+
decide(code: string, decision: "approve" | "deny"): Promise<{ ok: true } | OAuthRefusal>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type DeviceState =
|
|
125
|
+
/** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
|
|
126
|
+
| { status: "typing" }
|
|
127
|
+
| { status: "loading" }
|
|
128
|
+
/** Unknown, expired or already decided: ask them to start again from the app. */
|
|
129
|
+
| { status: "refused"; refusal: OAuthRefusal }
|
|
130
|
+
| { status: "failed"; error: unknown; retry: () => void }
|
|
131
|
+
/**
|
|
132
|
+
* Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
|
|
133
|
+
* this is the code on your screen. Somebody can run the real script and send a stranger the
|
|
134
|
+
* link, and that sentence is the only guard against it (RFC 8628 §5.4).
|
|
135
|
+
*/
|
|
136
|
+
| { status: "ready"; view: DeviceView }
|
|
137
|
+
/** Done. Tell them to go back to the app, which carries on by itself. */
|
|
138
|
+
| { status: "approved" }
|
|
139
|
+
| { status: "denied" };
|
|
140
|
+
|
|
141
|
+
export interface DeviceApproval {
|
|
142
|
+
state: DeviceState;
|
|
143
|
+
/** What the field shows, formatted as they type: `WDJB-MJ`. */
|
|
144
|
+
code: string;
|
|
145
|
+
setCode(input: string): void;
|
|
146
|
+
/** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
|
|
147
|
+
invalidLetter: boolean;
|
|
148
|
+
approve(): void;
|
|
149
|
+
deny(): void;
|
|
150
|
+
deciding: boolean;
|
|
151
|
+
/** The decision did not answer. Show it beside the buttons; pressing again retries. */
|
|
152
|
+
decideError: unknown;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The server's alphabet: no vowels, so a code never spells a word. */
|
|
156
|
+
const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
|
|
157
|
+
const LENGTH = 8;
|
|
158
|
+
|
|
159
|
+
/** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
|
|
160
|
+
export function formatUserCode(input: string): string {
|
|
161
|
+
const letters = input
|
|
162
|
+
.toUpperCase()
|
|
163
|
+
.replace(/[^A-Z]/g, "")
|
|
164
|
+
.slice(0, LENGTH);
|
|
165
|
+
return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* const device = useDeviceApproval(api.device, useLocation().search);
|
|
170
|
+
* <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
|
|
171
|
+
*
|
|
172
|
+
* The link the script prints carries `?code=`, so the field usually starts full.
|
|
173
|
+
*/
|
|
174
|
+
export function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval {
|
|
175
|
+
const [code, setTyped] = useState(() =>
|
|
176
|
+
formatUserCode(new URLSearchParams(search).get("code") ?? ""),
|
|
177
|
+
);
|
|
178
|
+
const letters = code.replace("-", "");
|
|
179
|
+
const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
|
|
180
|
+
const complete = letters.length === LENGTH && !invalidLetter;
|
|
181
|
+
|
|
182
|
+
const decision = useMutation({
|
|
183
|
+
mutationFn: (choice: "approve" | "deny") => api.decide(letters, choice),
|
|
184
|
+
});
|
|
185
|
+
const decided = decision.data?.ok === true ? decision.variables : undefined;
|
|
186
|
+
const request = useQuery({
|
|
187
|
+
queryKey: ["@gusnips/react/oauth", "device", letters],
|
|
188
|
+
queryFn: () => api.describe(letters),
|
|
189
|
+
enabled: complete && decided === undefined,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const answer = request.data;
|
|
193
|
+
const refusal =
|
|
194
|
+
decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
|
|
195
|
+
let state: DeviceState;
|
|
196
|
+
if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
|
|
197
|
+
else if (!complete) state = { status: "typing" };
|
|
198
|
+
else if (refusal) state = { status: "refused", refusal };
|
|
199
|
+
else if (answer?.ok === true) state = { status: "ready", view: answer };
|
|
200
|
+
else if (request.isError)
|
|
201
|
+
state = { status: "failed", error: request.error, retry: () => void request.refetch() };
|
|
202
|
+
else state = { status: "loading" };
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
state,
|
|
206
|
+
code,
|
|
207
|
+
setCode: (input) => {
|
|
208
|
+
decision.reset();
|
|
209
|
+
setTyped(formatUserCode(input));
|
|
210
|
+
},
|
|
211
|
+
invalidLetter,
|
|
212
|
+
approve: () => decision.mutate("approve"),
|
|
213
|
+
deny: () => decision.mutate("deny"),
|
|
214
|
+
deciding: decision.isPending,
|
|
215
|
+
decideError: decision.error,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isAuthOutage,
|
|
13
13
|
parseAuthCallback,
|
|
14
14
|
signOutEvenOffline,
|
|
15
|
+
signOutOtherDevices,
|
|
15
16
|
supabaseAuthStorage,
|
|
16
17
|
supabaseStorageKey,
|
|
17
18
|
type SupabaseSessionAuth,
|
|
@@ -75,10 +76,11 @@ const unreachable: typeof fetch = async () => {
|
|
|
75
76
|
throw new TypeError("fetch failed");
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
/** `paths` collects each request's path and query, so a test can read the scope `/logout` got. */
|
|
78
80
|
function reachable(paths: string[] = []): typeof fetch {
|
|
79
81
|
return async (input) => {
|
|
80
|
-
const { pathname } = new URL(input instanceof Request ? input.url : input);
|
|
81
|
-
paths.push(pathname);
|
|
82
|
+
const { pathname, search } = new URL(input instanceof Request ? input.url : input);
|
|
83
|
+
paths.push(pathname + search);
|
|
82
84
|
if (pathname.endsWith("/token")) return Response.json(storedSession(now() + 3600));
|
|
83
85
|
if (pathname.endsWith("/logout")) return new Response(null, { status: 204 });
|
|
84
86
|
return Response.json({ msg: "not part of this test" }, { status: 404 });
|
|
@@ -204,7 +206,8 @@ describe("createSupabaseSessionAdapter", () => {
|
|
|
204
206
|
const adapter = createSupabaseSessionAdapter(auth({ signOut }));
|
|
205
207
|
|
|
206
208
|
await expect(adapter.signOut()).rejects.toBe(refusal);
|
|
207
|
-
|
|
209
|
+
// This device only: a token one device's API refused says nothing about the others.
|
|
210
|
+
expect(signOut).toHaveBeenCalledExactlyOnceWith({ scope: "local" });
|
|
208
211
|
});
|
|
209
212
|
|
|
210
213
|
it("with `stored`, rejects when auth never heard it, but the session has left", async () => {
|
|
@@ -273,19 +276,57 @@ describe("signOutEvenOffline", () => {
|
|
|
273
276
|
expect(await sessionAfterReload(stored)).toBeNull();
|
|
274
277
|
});
|
|
275
278
|
|
|
276
|
-
it("ends
|
|
279
|
+
it("ends this device's session on the server too when auth answers, and no other", async () => {
|
|
277
280
|
const paths: string[] = [];
|
|
278
281
|
const { stored, auth, signedOut } = await signedIn(now() + 3600, reachable(paths));
|
|
279
282
|
|
|
280
283
|
const { error } = await signOutEvenOffline(auth, stored);
|
|
281
284
|
|
|
282
285
|
expect(error).toBeNull();
|
|
283
|
-
|
|
286
|
+
// auth-js's default, scope=global, would end the person's sessions on every device.
|
|
287
|
+
expect(paths).toContain("/auth/v1/logout?scope=local");
|
|
284
288
|
expect(signedOut()).toBe(1);
|
|
285
289
|
expect(await sessionAfterReload(stored)).toBeNull();
|
|
286
290
|
});
|
|
287
291
|
});
|
|
288
292
|
|
|
293
|
+
describe("signOutOtherDevices", () => {
|
|
294
|
+
it("ends the other sessions and keeps this one", async () => {
|
|
295
|
+
const paths: string[] = [];
|
|
296
|
+
const { stored, auth, signedOut } = await signedIn(now() + 3600, reachable(paths));
|
|
297
|
+
|
|
298
|
+
const { error } = await signOutOtherDevices(auth);
|
|
299
|
+
|
|
300
|
+
expect(error).toBeNull();
|
|
301
|
+
expect(paths).toContain("/auth/v1/logout?scope=others");
|
|
302
|
+
expect(signedOut()).toBe(0);
|
|
303
|
+
expect(await sessionAfterReload(stored)).not.toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("keeps this device signed in and says why when auth cannot be reached", async () => {
|
|
307
|
+
const { stored, auth, signedOut } = await signedIn(now() + 3600);
|
|
308
|
+
|
|
309
|
+
const { error } = await signOutOtherDevices(auth);
|
|
310
|
+
|
|
311
|
+
expect(error).toBeInstanceOf(AuthRetryableFetchError);
|
|
312
|
+
expect(signedOut()).toBe(0);
|
|
313
|
+
expect(await sessionAfterReload(stored)).not.toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("gives up at the deadline instead of waiting out the refresh backoff", async () => {
|
|
317
|
+
vi.useFakeTimers();
|
|
318
|
+
const { stored, auth } = await signedIn(now() - 60);
|
|
319
|
+
|
|
320
|
+
const signingOut = signOutOtherDevices(auth);
|
|
321
|
+
await vi.advanceTimersByTimeAsync(SIGN_OUT_TIMEOUT_MS);
|
|
322
|
+
const { error } = await signingOut;
|
|
323
|
+
|
|
324
|
+
expect(error).toMatchObject({ message: expect.stringContaining(`${SIGN_OUT_TIMEOUT_MS} ms`) });
|
|
325
|
+
expect(await stored.storage.getItem(stored.storageKey)).not.toBeNull();
|
|
326
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
289
330
|
describe("supabaseStorageKey", () => {
|
|
290
331
|
it("names the key supabase-js picks when you set none", async () => {
|
|
291
332
|
const storage = memoryStorage();
|
package/src/supabase-session.ts
CHANGED
|
@@ -156,11 +156,17 @@ function localStorageOrMemory(): SupportedStorage {
|
|
|
156
156
|
* it touches storage. This happens at every version, and it is the common case: a laptop that
|
|
157
157
|
* wakes up offline with a tab still open.
|
|
158
158
|
* - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
|
|
159
|
-
* before it removes anything.
|
|
159
|
+
* before it removes anything.
|
|
160
160
|
*
|
|
161
161
|
* Either way a reload reads the session and signs the person back in. On a shared computer, that
|
|
162
162
|
* person is whoever sits down next. A screen that says "signed out" at that point is wrong.
|
|
163
163
|
*
|
|
164
|
+
* It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
|
|
165
|
+
* cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
|
|
166
|
+
* every session the person has. Signing out of a laptop then signed them out of their phone, and a
|
|
167
|
+
* tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
|
|
168
|
+
* Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
|
|
169
|
+
*
|
|
164
170
|
* Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
|
|
165
171
|
* listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
|
|
166
172
|
* this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
|
|
@@ -173,7 +179,7 @@ function localStorageOrMemory(): SupportedStorage {
|
|
|
173
179
|
*
|
|
174
180
|
* It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
|
|
175
181
|
* server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
|
|
176
|
-
* The session may still be live on the server
|
|
182
|
+
* The session may still be live on the server. `SIGNED_OUT` arrives once
|
|
177
183
|
* auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
|
|
178
184
|
* later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
|
|
179
185
|
* of waiting for the event.
|
|
@@ -181,6 +187,40 @@ function localStorageOrMemory(): SupportedStorage {
|
|
|
181
187
|
export async function signOutEvenOffline(
|
|
182
188
|
auth: Pick<SupabaseSessionAuth, "signOut">,
|
|
183
189
|
{ storage, storageKey }: SupabaseSessionStorage,
|
|
190
|
+
): Promise<{ error: unknown }> {
|
|
191
|
+
const { error } = await withinSignOutDeadline(auth.signOut({ scope: "local" }));
|
|
192
|
+
if (error == null) return { error: null };
|
|
193
|
+
// From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
|
|
194
|
+
// already sent SIGNED_OUT. Going on would send it a second time.
|
|
195
|
+
if ((await storage.getItem(storageKey)) === null) return { error };
|
|
196
|
+
await storage.removeItem(storageKey);
|
|
197
|
+
// Not awaited: inside auth-js it can wait behind the first call, which may still be in its
|
|
198
|
+
// refresh backoff. The session is already out of storage, and storage is what a reload reads.
|
|
199
|
+
void auth.signOut({ scope: "local" }).catch(() => {});
|
|
200
|
+
return { error };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
|
|
205
|
+
* of other devices" button in account settings: a phone that was lost, a computer left signed in.
|
|
206
|
+
*
|
|
207
|
+
* It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
|
|
208
|
+
* it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
|
|
209
|
+
* so the person is still signed in and can press the button again.
|
|
210
|
+
*
|
|
211
|
+
* Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
|
|
212
|
+
* offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
|
|
213
|
+
* prove the other sessions are still live: auth may end them after this gave up.
|
|
214
|
+
*/
|
|
215
|
+
export function signOutOtherDevices(
|
|
216
|
+
auth: Pick<SupabaseSessionAuth, "signOut">,
|
|
217
|
+
): Promise<{ error: unknown }> {
|
|
218
|
+
return withinSignOutDeadline(auth.signOut({ scope: "others" }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `call`'s result, or the deadline as its error once {@link SIGN_OUT_TIMEOUT_MS} has passed. */
|
|
222
|
+
async function withinSignOutDeadline(
|
|
223
|
+
call: Promise<{ error: unknown }>,
|
|
184
224
|
): Promise<{ error: unknown }> {
|
|
185
225
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
186
226
|
const deadline = new Promise<{ error: unknown }>((resolve) => {
|
|
@@ -192,20 +232,12 @@ export async function signOutEvenOffline(
|
|
|
192
232
|
SIGN_OUT_TIMEOUT_MS,
|
|
193
233
|
);
|
|
194
234
|
});
|
|
195
|
-
const
|
|
196
|
-
|
|
235
|
+
const result = await Promise.race([
|
|
236
|
+
call.catch((thrown: unknown) => ({ error: thrown })),
|
|
197
237
|
deadline,
|
|
198
238
|
]);
|
|
199
239
|
clearTimeout(timer);
|
|
200
|
-
|
|
201
|
-
// From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
|
|
202
|
-
// already sent SIGNED_OUT. Going on would send it a second time.
|
|
203
|
-
if ((await storage.getItem(storageKey)) === null) return { error };
|
|
204
|
-
await storage.removeItem(storageKey);
|
|
205
|
-
// Not awaited: inside auth-js it can wait behind the first call, which may still be in its
|
|
206
|
-
// refresh backoff. The session is already out of storage, and storage is what a reload reads.
|
|
207
|
-
void auth.signOut({ scope: "local" }).catch(() => {});
|
|
208
|
-
return { error };
|
|
240
|
+
return result;
|
|
209
241
|
}
|
|
210
242
|
|
|
211
243
|
/**
|
|
@@ -220,7 +252,8 @@ export async function signOutEvenOffline(
|
|
|
220
252
|
* rejection means. It still rejects when auth did not end the session, but by then the session
|
|
221
253
|
* has already left this device. And a sign-out auth has not answered within
|
|
222
254
|
* {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
|
|
223
|
-
* `auth.signOut()` and rejects on that call's error
|
|
255
|
+
* `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
|
|
256
|
+
* device's session only: a token one device's API refused says nothing about the others.
|
|
224
257
|
*
|
|
225
258
|
* `stored` is optional here but not at a sign-out button. The API client signs out only after
|
|
226
259
|
* auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
|
|
@@ -247,7 +280,9 @@ export function createSupabaseSessionAdapter(
|
|
|
247
280
|
}
|
|
248
281
|
},
|
|
249
282
|
signOut: async () => {
|
|
250
|
-
const { error } = stored
|
|
283
|
+
const { error } = stored
|
|
284
|
+
? await signOutEvenOffline(auth, stored)
|
|
285
|
+
: await auth.signOut({ scope: "local" });
|
|
251
286
|
if (error) throw error;
|
|
252
287
|
},
|
|
253
288
|
};
|