@gusnips/react 0.9.1 → 0.9.2
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 +18 -0
- package/dist/guards.d.ts.map +1 -1
- package/dist/guards.js +7 -3
- package/dist/guards.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +21 -0
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +32 -0
- package/dist/internal-path.js.map +1 -1
- package/package.json +1 -1
- package/src/guards.test.ts +15 -0
- package/src/guards.tsx +7 -3
- package/src/index.ts +1 -1
- package/src/internal-path.test.ts +46 -1
- package/src/internal-path.ts +41 -0
package/README.md
CHANGED
|
@@ -183,6 +183,24 @@ const next = safeInternalPath(new URLSearchParams(location.search).get("next"))
|
|
|
183
183
|
|
|
184
184
|
`createRequireAnonymous` already performs that check and falls back to the home path you gave it.
|
|
185
185
|
|
|
186
|
+
Writing a return target down is the other direction, and it has its own hazard. A Supabase client
|
|
187
|
+
with no `flowType` is on the **implicit** flow — the default — so a callback comes back as
|
|
188
|
+
`#access_token=…&refresh_token=…`. A guard renders exactly when there is no session yet, which is
|
|
189
|
+
that callback's own state while the client parses the fragment: point an OAuth `redirectTo` at a
|
|
190
|
+
guarded route and a concatenated `pathname + search + hash` puts a refresh token into a query
|
|
191
|
+
string, where it rides in `Referer`, lands in access logs and stays in history.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { returnPathFromLocation } from "@gusnips/react";
|
|
195
|
+
|
|
196
|
+
// Takes anything shaped like the current page: `window.location`, or `useLocation()`.
|
|
197
|
+
const returnTo = returnPathFromLocation(window.location);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
It keeps the page and drops only a fragment carrying a credential, because the page is the part
|
|
201
|
+
worth returning to. `createRequireAuth` uses it; use it anywhere else you record where someone was
|
|
202
|
+
— a dead-session redirect is the common one.
|
|
203
|
+
|
|
186
204
|
## Never dead-end anyone
|
|
187
205
|
|
|
188
206
|
```ts
|
package/dist/guards.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;;;;;;;GAaG;AAEH,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,OAAO,CAAC,GAAG,IACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC;AAajG,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAOxC;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,YAAY,EAC9B,UAAU,EAAE,MAAM,EAClB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEG,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;;;;;;;GAaG;AAEH,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,OAAO,CAAC,GAAG,IACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC;AAajG,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAOxC;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,YAAY,EAC9B,UAAU,EAAE,MAAM,EAClB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEG,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAiBlE;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,MAAM,YAAY,EAC9B,QAAQ,EAAE,MAAM,EAChB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEQ,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAUvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,YAAY,IAE1F,OAAO,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,EAC3B,UAAU,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC,EACvC,SAAS,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAEP,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAQvE"}
|
package/dist/guards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Navigate, useLocation } from "react-router-dom";
|
|
3
|
-
import { safeInternalPath } from "./internal-path.js";
|
|
3
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
4
4
|
/** Query parameter carrying the route a sign-in interrupted. */
|
|
5
5
|
export const AUTH_RETURN_PARAM = "next";
|
|
6
6
|
function signInUrl(signInPath, returnTo) {
|
|
@@ -20,8 +20,12 @@ export function createRequireAuth(useSession, signInPath, { loading }) {
|
|
|
20
20
|
if (!isAuthenticated) {
|
|
21
21
|
// Query state survives a reload, an OAuth round trip and an email-link round trip. Router
|
|
22
22
|
// state survives none of them, so it only looked like a remembered destination on the
|
|
23
|
-
// password path. The hash belongs too: tabs and anchored settings are real destinations
|
|
24
|
-
|
|
23
|
+
// password path. The hash belongs too: tabs and anchored settings are real destinations —
|
|
24
|
+
// but not when it is the round trip itself, which is why this is not a concatenation. This
|
|
25
|
+
// guard renders exactly when there is no session yet, and that is the state an implicit-flow
|
|
26
|
+
// callback is in while its client parses the fragment: point an OAuth `redirectTo` at a
|
|
27
|
+
// guarded route and the raw hash is a refresh token, on its way into a query string.
|
|
28
|
+
const returnTo = returnPathFromLocation(location);
|
|
25
29
|
return _jsx(Navigate, { to: signInUrl(signInPath, returnTo), replace: true });
|
|
26
30
|
}
|
|
27
31
|
return _jsx(_Fragment, { children: children });
|
package/dist/guards.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AA0D9E,gEAAgE;AAChE,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC;AAExC,SAAS,SAAS,CAAC,UAAkB,EAAE,QAAgB;IACrD,MAAM,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IACvD,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,iBAAiB,IAAI,kBAAkB,CAAC,QAAQ,CAAC,EAAE,CAAC;AACzF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAA8B,EAC9B,UAAkB,EAClB,EAAE,OAAO,EAAgB;IAEzB,OAAO,SAAS,WAAW,CAAC,EAAE,QAAQ,EAA2B;QAC/D,MAAM,EAAE,eAAe,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAAC;QACpD,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;QAC/B,IAAI,SAAS;YAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;QACrC,IAAI,CAAC,eAAe,EAAE,CAAC;YACrB,0FAA0F;YAC1F,sFAAsF;YACtF,0FAA0F;YAC1F,2FAA2F;YAC3F,6FAA6F;YAC7F,wFAAwF;YACxF,qFAAqF;YACrF,MAAM,QAAQ,GAAG,sBAAsB,CAAC,QAAQ,CAAC,CAAC;YAClD,OAAO,KAAC,QAAQ,IAAC,EAAE,EAAE,SAAS,CAAC,UAAU,EAAE,QAAQ,CAAC,EAAE,OAAO,SAAG,CAAC;QACnE,CAAC;QACD,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACzB,CAAC,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,sBAAsB,CACpC,UAA8B,EAC9B,QAAgB,EAChB,EAAE,OAAO,EAAgB;IAEzB,OAAO,SAAS,gBAAgB,CAAC,EAAE,QAAQ,EAA2B;QACpE,MAAM,EAAE,eAAe,EAAE,SAAS,EAAE,GAAG,UAAU,EAAE,CAAC;QACpD,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAC;QAC/B,IAAI,SAAS;YAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;QACrC,IAAI,eAAe,EAAE,CAAC;YACpB,MAAM,OAAO,GAAG,IAAI,eAAe,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;YAC5E,OAAO,KAAC,QAAQ,IAAC,EAAE,EAAE,gBAAgB,CAAC,OAAO,CAAC,IAAI,QAAQ,EAAE,OAAO,SAAG,CAAC;QACzE,CAAC;QACD,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACzB,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAM,KAAyB,EAAE,EAAE,OAAO,EAAgB;IAC5F,OAAO,SAAS,cAAc,CAC5B,KAA2B,EAC3B,QAAuC,EACvC,OAAsC;QAEtC,OAAO,SAAS,cAAc,CAAC,EAAE,QAAQ,EAA2B;YAClE,MAAM,KAAK,GAAG,KAAK,EAAE,CAAC;YACtB,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS;gBAAE,OAAO,4BAAG,OAAO,GAAI,CAAC;YACtD,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;gBAAE,OAAO,4BAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAI,CAAC;YACjE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC;gBAAE,OAAO,4BAAG,OAAO,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAI,CAAC;YAC7F,OAAO,4BAAG,QAAQ,GAAI,CAAC;QACzB,CAAC,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { cn } from "./cn.ts";
|
|
2
|
-
export { safeInternalPath } from "./internal-path.ts";
|
|
2
|
+
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
3
3
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
4
4
|
export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
|
|
5
5
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,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,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,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,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
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// Check that claim against `dist/`, never against this file: a type-only re-export looks
|
|
29
29
|
// identical in source.
|
|
30
30
|
export { cn } from "./cn.js";
|
|
31
|
-
export { safeInternalPath } from "./internal-path.js";
|
|
31
|
+
export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
32
32
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.js";
|
|
33
33
|
export { createApiClient, } from "./api-client.js";
|
|
34
34
|
export { createSseParser, readSseStream } from "./sse.js";
|
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,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,
|
|
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,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,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,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/internal-path.d.ts
CHANGED
|
@@ -7,4 +7,25 @@
|
|
|
7
7
|
* Parsing against a fixed origin applies the browser's own rules rather than trying to copy them.
|
|
8
8
|
*/
|
|
9
9
|
export declare function safeInternalPath(value: unknown): string | null;
|
|
10
|
+
/** Anything shaped like the current page: `window.location` and react-router's `useLocation()`. */
|
|
11
|
+
export interface PathParts {
|
|
12
|
+
pathname: string;
|
|
13
|
+
search: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
18
|
+
*
|
|
19
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
20
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
21
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
22
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
23
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
24
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
25
|
+
* dangerous.
|
|
26
|
+
*
|
|
27
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
28
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
29
|
+
*/
|
|
30
|
+
export declare function returnPathFromLocation({ pathname, search, hash }: PathParts): string;
|
|
10
31
|
//# sourceMappingURL=internal-path.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal-path.d.ts","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAUA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU9D"}
|
|
1
|
+
{"version":3,"file":"internal-path.d.ts","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAUA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU9D;AAeD,mGAAmG;AACnG,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,SAAS,GAAG,MAAM,CAKpF"}
|
package/dist/internal-path.js
CHANGED
|
@@ -28,4 +28,36 @@ export function safeInternalPath(value) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The keys an OAuth or OIDC implicit response puts in the fragment that are themselves credentials
|
|
33
|
+
* (RFC 6749 §4.2.2, plus the two a provider token is passed through under). The rest of that
|
|
34
|
+
* response — `expires_in`, `token_type`, `state`, `type` — is metadata, worthless on its own.
|
|
35
|
+
*/
|
|
36
|
+
const CREDENTIAL_FRAGMENT_KEYS = [
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"id_token",
|
|
40
|
+
"provider_token",
|
|
41
|
+
"provider_refresh_token",
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
45
|
+
*
|
|
46
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
47
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
48
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
49
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
50
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
51
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
52
|
+
* dangerous.
|
|
53
|
+
*
|
|
54
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
55
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
56
|
+
*/
|
|
57
|
+
export function returnPathFromLocation({ pathname, search, hash }) {
|
|
58
|
+
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
59
|
+
const params = new URLSearchParams(fragment);
|
|
60
|
+
const carriesCredential = CREDENTIAL_FRAGMENT_KEYS.some((key) => params.has(key));
|
|
61
|
+
return `${pathname}${search}${carriesCredential ? "" : hash}`;
|
|
62
|
+
}
|
|
31
63
|
//# sourceMappingURL=internal-path.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal-path.js","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAAA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,SAAS,eAAe,CAAC,KAAa;IACpC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAChC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/F,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC;QAC5C,IAAI,GAAG,CAAC,MAAM,KAAK,eAAe;YAAE,OAAO,IAAI,CAAC;QAChD,OAAO,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
1
|
+
{"version":3,"file":"internal-path.js","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAAA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,SAAS,eAAe,CAAC,KAAa;IACpC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAChC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/F,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC;QAC5C,IAAI,GAAG,CAAC,MAAM,KAAK,eAAe;YAAE,OAAO,IAAI,CAAC;QAChD,OAAO,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,wBAAwB,GAAG;IAC/B,cAAc;IACd,eAAe;IACf,UAAU;IACV,gBAAgB;IAChB,wBAAwB;CACzB,CAAC;AASF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAa;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7D,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,QAAQ,CAAC,CAAC;IAC7C,MAAM,iBAAiB,GAAG,wBAAwB,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAClF,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAChE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
package/src/guards.test.ts
CHANGED
|
@@ -105,6 +105,21 @@ describe("createRequireAuth", () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// Point an OAuth `redirectTo` at a guarded route and this is the first render after the provider
|
|
109
|
+
// returns: no session yet, and the implicit flow's tokens still sitting in the address bar. The
|
|
110
|
+
// page is worth remembering; the fragment would be a refresh token written into a query string.
|
|
111
|
+
it("refuses to carry a callback's tokens into the sign-in URL", () => {
|
|
112
|
+
router.useLocation.mockReturnValueOnce({
|
|
113
|
+
pathname: "/dashboard",
|
|
114
|
+
search: "?tab=usage",
|
|
115
|
+
hash: "#access_token=eyJhbG.p.s&refresh_token=v1_abc&token_type=bearer",
|
|
116
|
+
});
|
|
117
|
+
expect(branch(render(guard(false)))).toEqual({
|
|
118
|
+
to: "/login?next=%2Fdashboard%3Ftab%3Dusage",
|
|
119
|
+
state: undefined,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
108
123
|
it("lets a signed-in visitor through", () => {
|
|
109
124
|
expect(branch(render(guard(true)))).toBe("CONTENT");
|
|
110
125
|
});
|
package/src/guards.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { UseQueryResult } from "@tanstack/react-query";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Navigate, useLocation } from "react-router-dom";
|
|
4
|
-
import { safeInternalPath } from "./internal-path.ts";
|
|
4
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Route guards.
|
|
@@ -83,8 +83,12 @@ export function createRequireAuth(
|
|
|
83
83
|
if (!isAuthenticated) {
|
|
84
84
|
// Query state survives a reload, an OAuth round trip and an email-link round trip. Router
|
|
85
85
|
// state survives none of them, so it only looked like a remembered destination on the
|
|
86
|
-
// password path. The hash belongs too: tabs and anchored settings are real destinations
|
|
87
|
-
|
|
86
|
+
// password path. The hash belongs too: tabs and anchored settings are real destinations —
|
|
87
|
+
// but not when it is the round trip itself, which is why this is not a concatenation. This
|
|
88
|
+
// guard renders exactly when there is no session yet, and that is the state an implicit-flow
|
|
89
|
+
// callback is in while its client parses the fragment: point an OAuth `redirectTo` at a
|
|
90
|
+
// guarded route and the raw hash is a refresh token, on its way into a query string.
|
|
91
|
+
const returnTo = returnPathFromLocation(location);
|
|
88
92
|
return <Navigate to={signInUrl(signInPath, returnTo)} replace />;
|
|
89
93
|
}
|
|
90
94
|
return <>{children}</>;
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// identical in source.
|
|
30
30
|
|
|
31
31
|
export { cn } from "./cn.ts";
|
|
32
|
-
export { safeInternalPath } from "./internal-path.ts";
|
|
32
|
+
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
33
33
|
|
|
34
34
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
35
35
|
export {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { safeInternalPath } from "./internal-path.ts";
|
|
2
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.ts";
|
|
3
3
|
|
|
4
4
|
describe("safeInternalPath", () => {
|
|
5
5
|
it.each([
|
|
@@ -26,3 +26,48 @@ describe("safeInternalPath", () => {
|
|
|
26
26
|
expect(safeInternalPath(value)).toBeNull();
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
describe("returnPathFromLocation", () => {
|
|
31
|
+
it("keeps the whole address, fragment included", () => {
|
|
32
|
+
expect(
|
|
33
|
+
returnPathFromLocation({
|
|
34
|
+
pathname: "/w/acme/products/api/changes",
|
|
35
|
+
search: "?feature=f1",
|
|
36
|
+
hash: "#change-8f3",
|
|
37
|
+
}),
|
|
38
|
+
).toBe("/w/acme/products/api/changes?feature=f1#change-8f3");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.each([
|
|
42
|
+
"access_token",
|
|
43
|
+
"refresh_token",
|
|
44
|
+
"id_token",
|
|
45
|
+
"provider_token",
|
|
46
|
+
"provider_refresh_token",
|
|
47
|
+
])("drops a fragment carrying %s, and keeps the page", (key) => {
|
|
48
|
+
expect(
|
|
49
|
+
returnPathFromLocation({
|
|
50
|
+
pathname: "/app",
|
|
51
|
+
search: "?tab=repos",
|
|
52
|
+
hash: `#${key}=ey.J.h&expires_in=3600&token_type=bearer`,
|
|
53
|
+
}),
|
|
54
|
+
).toBe("/app?tab=repos");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("drops the whole implicit response, not only its credential keys", () => {
|
|
58
|
+
// Verbatim shape of what a Supabase client with no `flowType` puts back in the address bar.
|
|
59
|
+
const hash =
|
|
60
|
+
"#access_token=eyJhbG.payload.sig&expires_at=1790000000&expires_in=3600" +
|
|
61
|
+
"&provider_token=gho_x&refresh_token=v1_abc&token_type=bearer&type=magiclink";
|
|
62
|
+
expect(returnPathFromLocation({ pathname: "/app", search: "", hash })).toBe("/app");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a fragment that only looks like parameters", () => {
|
|
66
|
+
const at = { pathname: "/docs", search: "", hash: "#tab=repos&expires_in=3600" };
|
|
67
|
+
expect(returnPathFromLocation(at)).toBe("/docs#tab=repos&expires_in=3600");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles an address with no fragment at all", () => {
|
|
71
|
+
expect(returnPathFromLocation({ pathname: "/", search: "", hash: "" })).toBe("/");
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/internal-path.ts
CHANGED
|
@@ -27,3 +27,44 @@ export function safeInternalPath(value: unknown): string | null {
|
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The keys an OAuth or OIDC implicit response puts in the fragment that are themselves credentials
|
|
33
|
+
* (RFC 6749 §4.2.2, plus the two a provider token is passed through under). The rest of that
|
|
34
|
+
* response — `expires_in`, `token_type`, `state`, `type` — is metadata, worthless on its own.
|
|
35
|
+
*/
|
|
36
|
+
const CREDENTIAL_FRAGMENT_KEYS = [
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"id_token",
|
|
40
|
+
"provider_token",
|
|
41
|
+
"provider_refresh_token",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Anything shaped like the current page: `window.location` and react-router's `useLocation()`. */
|
|
45
|
+
export interface PathParts {
|
|
46
|
+
pathname: string;
|
|
47
|
+
search: string;
|
|
48
|
+
hash: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
53
|
+
*
|
|
54
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
55
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
56
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
57
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
58
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
59
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
60
|
+
* dangerous.
|
|
61
|
+
*
|
|
62
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
63
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
64
|
+
*/
|
|
65
|
+
export function returnPathFromLocation({ pathname, search, hash }: PathParts): string {
|
|
66
|
+
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
67
|
+
const params = new URLSearchParams(fragment);
|
|
68
|
+
const carriesCredential = CREDENTIAL_FRAGMENT_KEYS.some((key) => params.has(key));
|
|
69
|
+
return `${pathname}${search}${carriesCredential ? "" : hash}`;
|
|
70
|
+
}
|