@gusnips/react 0.9.4 → 0.9.6
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 +67 -9
- package/dist/supabase-session.d.ts +74 -1
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +64 -1
- package/dist/supabase-session.js.map +1 -1
- package/package.json +1 -1
- package/src/supabase-session.test.ts +91 -0
- package/src/supabase-session.ts +112 -1
package/README.md
CHANGED
|
@@ -75,6 +75,10 @@ anywhere that decision is made; the sentence you show is a separate question, an
|
|
|
75
75
|
— "check your connection" is true of a request that never landed and false of a 502, which is
|
|
76
76
|
ours. auth-js marks "nothing came back", and only that, with status 0.
|
|
77
77
|
|
|
78
|
+
It is safe to hand it the `error` from a `{ data, error }` result without checking it first:
|
|
79
|
+
`null` answers false. That is worth stating because `null` is the SUCCESS value of every
|
|
80
|
+
supabase-js auth call, so the obvious `if (isAuthOutage(error))` is written against it constantly.
|
|
81
|
+
|
|
78
82
|
Supabase email links have two complete patterns. Keep either one, never half of each:
|
|
79
83
|
|
|
80
84
|
- A link carrying `token_hash` needs one explicit `verifyOtp` call. Guard it against React
|
|
@@ -82,11 +86,65 @@ Supabase email links have two complete patterns. Keep either one, never half of
|
|
|
82
86
|
- A link carrying `ConfirmationURL` needs `detectSessionInUrl` enabled. That reader consumes an
|
|
83
87
|
implicit `#access_token` or a PKCE `?code`; it does not consume `token_hash`.
|
|
84
88
|
|
|
89
|
+
Either pattern can carry a destination — the `token_hash` one has to be asked. GoTrue hands every
|
|
90
|
+
template a `{{ .RedirectTo }}`: the `redirectTo` the app passed, already checked against the
|
|
91
|
+
allowlist, falling back to the Site URL when it was absent or not on it. A `{{ .ConfirmationURL }}`
|
|
92
|
+
link carries it for you. A template that writes its own landing drops it unless it asks:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
{{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Leave that parameter off and `redirectTo` and `emailRedirectTo` do nothing on every e-mail flow,
|
|
99
|
+
however carefully the app sets them — four of the codebases this came from pass one that the
|
|
100
|
+
template throws away, so a person bounced off a protected page signs in and lands on the front
|
|
101
|
+
door. Nothing reports it: the app's call is correct, the link works, and the destination was
|
|
102
|
+
dropped in an HTML file hosted somewhere else.
|
|
103
|
+
|
|
104
|
+
`{{ .RedirectTo }}` arrives as an absolute URL rather than a path, so the landing reads it as one:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const raw = new URLSearchParams(location.search).get("next");
|
|
108
|
+
const url = raw ? new URL(raw, location.origin) : null;
|
|
109
|
+
const next = url?.origin === location.origin ? safeInternalPath(url.pathname + url.search) : null;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Validate it on arrival even though GoTrue already did: that check is against a list of origins,
|
|
113
|
+
so it says the link may come back here, not which page it may open.
|
|
114
|
+
|
|
85
115
|
Recovery and invite links continue to the new-password screen after the session opens. A callback
|
|
86
116
|
must also tell a used or expired link apart from a request that never landed — or one that landed
|
|
87
117
|
and came back 5xx: the first needs a new link, and the other two can retry the same one, because
|
|
88
118
|
the hash was never spent.
|
|
89
119
|
|
|
120
|
+
`parseAuthCallback` reads a callback address and says what arrived:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { parseAuthCallback } from "@gusnips/react/supabase";
|
|
124
|
+
|
|
125
|
+
parseAuthCallback("?token_hash=abc&type=recovery");
|
|
126
|
+
// → { kind: "email-link", tokenHash: "abc", type: "recovery" }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There are four kinds. `email-link` needs your one `verifyOtp` call. `session` is a PKCE `code` or
|
|
130
|
+
implicit-flow tokens; in a browser the client has already taken it, so wait for the session, and on
|
|
131
|
+
React Native hand `credential` to `exchangeCodeForSession` or `setSession`. `error` is a failure
|
|
132
|
+
GoTrue wrote into the address. `invalid` is a link that was cut short or edited.
|
|
133
|
+
|
|
134
|
+
Pass the fragment too — `parseAuthCallback(location.search, location.hash)`. GoTrue writes a
|
|
135
|
+
failure into the fragment every time and into the query only sometimes: after an implicit-flow
|
|
136
|
+
email link it is in the fragment alone, so a query-only reader shows nothing to somebody whose
|
|
137
|
+
link expired.
|
|
138
|
+
|
|
139
|
+
Choose the words by `code` (GoTrue's `error_code`, such as `otp_expired`), not by `error`.
|
|
140
|
+
`access_denied` covers an expired link, a banned user and a disabled signup as well as a person
|
|
141
|
+
pressing Cancel at Google; only the last one arrives with no code, and that is what `cancelled`
|
|
142
|
+
means. A cancel was their choice, so say nothing or offer the button again.
|
|
143
|
+
|
|
144
|
+
Every email link type is accepted, `invite` included, even if your templates never send it. An
|
|
145
|
+
operator can still send one from Studio. Name a destination for each with
|
|
146
|
+
`Record<EmailLinkType, string>`, so a new type is a compile error rather than a wrong page.
|
|
147
|
+
|
|
90
148
|
There is also a deadline on every request, which none of the codebases this came from had. A
|
|
91
149
|
request with no timeout is a spinner with no end.
|
|
92
150
|
|
|
@@ -339,15 +397,15 @@ something.
|
|
|
339
397
|
`@gusnips/react` itself needs `react` and nothing else. Anything that needs another runtime peer
|
|
340
398
|
lives behind a subpath, so you install a dependency only if you import the thing that uses it:
|
|
341
399
|
|
|
342
|
-
| Import from | What is in it
|
|
343
|
-
| ------------------------- |
|
|
344
|
-
| `@gusnips/react` | the client and rest
|
|
345
|
-
| `@gusnips/react/store` | `createAuthStore`, `authSlice`
|
|
346
|
-
| `@gusnips/react/guards` | the route guards
|
|
347
|
-
| `@gusnips/react/hydrate` | `hydrateOrMount`
|
|
348
|
-
| `@gusnips/react/supabase` | the session adapter
|
|
349
|
-
| `@gusnips/react/ui` | the seven wrappers
|
|
350
|
-
| `@gusnips/react/contract` | two prerender names
|
|
400
|
+
| Import from | What is in it | What you must have |
|
|
401
|
+
| ------------------------- | ---------------------------------------- | --------------------- |
|
|
402
|
+
| `@gusnips/react` | the client and rest | react |
|
|
403
|
+
| `@gusnips/react/store` | `createAuthStore`, `authSlice` | zustand |
|
|
404
|
+
| `@gusnips/react/guards` | the route guards | react-router-dom |
|
|
405
|
+
| `@gusnips/react/hydrate` | `hydrateOrMount` | react-dom |
|
|
406
|
+
| `@gusnips/react/supabase` | the session adapter, `parseAuthCallback` | @supabase/supabase-js |
|
|
407
|
+
| `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
|
|
408
|
+
| `@gusnips/react/contract` | two prerender names | nothing |
|
|
351
409
|
|
|
352
410
|
The rule behind that table: **a peer marked optional must not be reachable from the main entry
|
|
353
411
|
point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
|
|
@@ -40,7 +40,8 @@ export interface SupabaseSessionAuth {
|
|
|
40
40
|
* heading per RESOLVED version; `node_modules` keeps copies the resolver does not serve.
|
|
41
41
|
*
|
|
42
42
|
* Anything that is not an auth error at all answers true: a failure from outside auth-js is not
|
|
43
|
-
* a verdict on the credential either.
|
|
43
|
+
* a verdict on the credential either. Nothing at all answers false — `null` is what every
|
|
44
|
+
* supabase-js auth call returns on success, and success is not an outage.
|
|
44
45
|
*/
|
|
45
46
|
export declare function isAuthOutage(error: unknown): boolean;
|
|
46
47
|
/**
|
|
@@ -52,5 +53,77 @@ export declare function isAuthOutage(error: unknown): boolean;
|
|
|
52
53
|
* stays on that side of the line too.
|
|
53
54
|
*/
|
|
54
55
|
export declare function createSupabaseSessionAdapter(auth: SupabaseSessionAuth): SessionAdapter;
|
|
56
|
+
/**
|
|
57
|
+
* Every link type a GoTrue email can carry — not only the ones this app's templates send today.
|
|
58
|
+
* One donor rejected `invite` because "invites are never sent", which was true of the product and
|
|
59
|
+
* false of the deployment: an operator inviting somebody from Studio sent a branded email whose
|
|
60
|
+
* link then said it was invalid. A type the templates never emit costs nothing to accept: the hash
|
|
61
|
+
* is the proof, and GoTrue opens a session only for the person whose one-time token it is.
|
|
62
|
+
*
|
|
63
|
+
* Closed on purpose: auth-js's own `EmailOtpType` ends in `(string & {})`, so it cannot make a
|
|
64
|
+
* destination map name every case. `Record<EmailLinkType, string>` can, and that is where an app
|
|
65
|
+
* decides that `recovery` and `invite` both continue to the new-password screen — GoTrue creates
|
|
66
|
+
* an invited account with NO password, so landing one "inside" signs them in once and locks them
|
|
67
|
+
* out after.
|
|
68
|
+
*/
|
|
69
|
+
export type EmailLinkType = "signup" | "invite" | "magiclink" | "recovery" | "email_change" | "email";
|
|
70
|
+
/** Everything an auth callback address can carry, decided from the address alone. */
|
|
71
|
+
export type AuthCallback =
|
|
72
|
+
/** An email link with a one-use `token_hash`: trade it with `verifyOtp`, exactly once. */
|
|
73
|
+
{
|
|
74
|
+
kind: "email-link";
|
|
75
|
+
tokenHash: string;
|
|
76
|
+
type: EmailLinkType;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A session arriving in the address: a PKCE `code`, or the implicit flow's tokens. In a browser
|
|
80
|
+
* with `detectSessionInUrl` on, the client has already taken it — wait for the session, and do
|
|
81
|
+
* not trade it again, which would be a second exchange racing the first. Where nothing reads
|
|
82
|
+
* the address for you (React Native), `credential` is what `exchangeCodeForSession` or
|
|
83
|
+
* `setSession` takes.
|
|
84
|
+
*/
|
|
85
|
+
| {
|
|
86
|
+
kind: "session";
|
|
87
|
+
type: EmailLinkType | null;
|
|
88
|
+
credential: {
|
|
89
|
+
code: string;
|
|
90
|
+
} | {
|
|
91
|
+
access_token: string;
|
|
92
|
+
refresh_token: string;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* GoTrue, or the provider behind it, reported a failure in the address. `code` is GoTrue's
|
|
97
|
+
* stable `error_code` (`otp_expired`, `bad_oauth_state`, …) and is what to choose words by;
|
|
98
|
+
* `error_description` is left out on purpose — it is English-only and often names the wrong
|
|
99
|
+
* cause.
|
|
100
|
+
*/
|
|
101
|
+
| {
|
|
102
|
+
kind: "error";
|
|
103
|
+
error: string | null;
|
|
104
|
+
code: string | null;
|
|
105
|
+
cancelled: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** Nothing usable: cut short between the mail client and here, or edited by hand. */
|
|
108
|
+
| {
|
|
109
|
+
kind: "invalid";
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Reads an auth callback address. Pure, so every edge case is testable without a router or a
|
|
113
|
+
* client — pass `location.search` and `location.hash`, or the two halves of a deep link.
|
|
114
|
+
*
|
|
115
|
+
* GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
|
|
116
|
+
* query too after a provider round trip, the fragment alone after an implicit-flow email link —
|
|
117
|
+
* so a callback reading one of the two misses a failure the other carries, and the person lands
|
|
118
|
+
* on a screen that says nothing. Both are read here.
|
|
119
|
+
*
|
|
120
|
+
* `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
|
|
121
|
+
* — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
|
|
122
|
+
* that same value for an expired email link, a banned user and a disabled signup, each with an
|
|
123
|
+
* `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
|
|
124
|
+
* test reads an expired link as a change of mind. And `error` itself is absent for a status
|
|
125
|
+
* outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
|
|
126
|
+
*/
|
|
127
|
+
export declare function parseAuthCallback(search: string | URLSearchParams, hash?: string): AuthCallback;
|
|
55
128
|
export {};
|
|
56
129
|
//# sourceMappingURL=supabase-session.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,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,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACxC;AAOD
|
|
1
|
+
{"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,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,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACxC;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASpD;AAaD;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,mBAAmB,GAAG,cAAc,CAsBtF;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GACvB,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,OAAO,CAAC;AAiB5E,qFAAqF;AACrF,MAAM,MAAM,YAAY;AACtB,0FAA0F;AACxF;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,aAAa,CAAA;CAAE;AAChE;;;;;;GAMG;GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF;AACH;;;;;GAKG;GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE;AAClF,qFAAqF;GACnF;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAExB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,IAAI,SAAK,GAAG,YAAY,CA6B3F"}
|
package/dist/supabase-session.js
CHANGED
|
@@ -23,9 +23,17 @@ import { isAuthError, isAuthRetryableFetchError } from "@supabase/supabase-js";
|
|
|
23
23
|
* heading per RESOLVED version; `node_modules` keeps copies the resolver does not serve.
|
|
24
24
|
*
|
|
25
25
|
* Anything that is not an auth error at all answers true: a failure from outside auth-js is not
|
|
26
|
-
* a verdict on the credential either.
|
|
26
|
+
* a verdict on the credential either. Nothing at all answers false — `null` is what every
|
|
27
|
+
* supabase-js auth call returns on success, and success is not an outage.
|
|
27
28
|
*/
|
|
28
29
|
export function isAuthOutage(error) {
|
|
30
|
+
// Nothing thrown is not an outage, and this clause exists because the function is exported.
|
|
31
|
+
// `null` is the SUCCESS value of every supabase-js auth call, so the obvious
|
|
32
|
+
// `if (isAuthOutage(error))` around a `getUser()` result has to be safe; without this it read
|
|
33
|
+
// every success as auth being down. An adopter had already written that guard at its own call
|
|
34
|
+
// site, which is what a check missing from in here looks like from the outside.
|
|
35
|
+
if (error == null)
|
|
36
|
+
return false;
|
|
29
37
|
if (!isAuthError(error))
|
|
30
38
|
return true;
|
|
31
39
|
return isAuthRetryableFetchError(error) || (error.status ?? 0) >= 500;
|
|
@@ -74,4 +82,59 @@ export function createSupabaseSessionAdapter(auth) {
|
|
|
74
82
|
},
|
|
75
83
|
};
|
|
76
84
|
}
|
|
85
|
+
const EMAIL_LINK_TYPES = {
|
|
86
|
+
signup: true,
|
|
87
|
+
invite: true,
|
|
88
|
+
magiclink: true,
|
|
89
|
+
recovery: true,
|
|
90
|
+
email_change: true,
|
|
91
|
+
email: true,
|
|
92
|
+
};
|
|
93
|
+
function isEmailLinkType(value) {
|
|
94
|
+
// Not `in`: `"toString" in EMAIL_LINK_TYPES` is true, and the type is typed by whoever sends
|
|
95
|
+
// the link.
|
|
96
|
+
return Object.hasOwn(EMAIL_LINK_TYPES, value);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reads an auth callback address. Pure, so every edge case is testable without a router or a
|
|
100
|
+
* client — pass `location.search` and `location.hash`, or the two halves of a deep link.
|
|
101
|
+
*
|
|
102
|
+
* GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
|
|
103
|
+
* query too after a provider round trip, the fragment alone after an implicit-flow email link —
|
|
104
|
+
* so a callback reading one of the two misses a failure the other carries, and the person lands
|
|
105
|
+
* on a screen that says nothing. Both are read here.
|
|
106
|
+
*
|
|
107
|
+
* `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
|
|
108
|
+
* — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
|
|
109
|
+
* that same value for an expired email link, a banned user and a disabled signup, each with an
|
|
110
|
+
* `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
|
|
111
|
+
* test reads an expired link as a change of mind. And `error` itself is absent for a status
|
|
112
|
+
* outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
|
|
113
|
+
*/
|
|
114
|
+
export function parseAuthCallback(search, hash = "") {
|
|
115
|
+
const query = new URLSearchParams(search);
|
|
116
|
+
const fragment = new URLSearchParams(hash.replace(/^#/, ""));
|
|
117
|
+
const read = (name) => query.get(name) || fragment.get(name) || null;
|
|
118
|
+
const error = read("error");
|
|
119
|
+
const code = read("error_code");
|
|
120
|
+
if (error || code || read("error_description"))
|
|
121
|
+
return { kind: "error", error, code, cancelled: error === "access_denied" && code === null };
|
|
122
|
+
const rawType = read("type") ?? "";
|
|
123
|
+
const type = isEmailLinkType(rawType) ? rawType : null;
|
|
124
|
+
const tokenHash = query.get("token_hash")?.trim();
|
|
125
|
+
if (tokenHash)
|
|
126
|
+
return type ? { kind: "email-link", tokenHash, type } : { kind: "invalid" };
|
|
127
|
+
const pkceCode = query.get("code");
|
|
128
|
+
if (pkceCode)
|
|
129
|
+
return { kind: "session", type, credential: { code: pkceCode } };
|
|
130
|
+
const accessToken = fragment.get("access_token");
|
|
131
|
+
const refreshToken = fragment.get("refresh_token");
|
|
132
|
+
if (accessToken && refreshToken)
|
|
133
|
+
return {
|
|
134
|
+
kind: "session",
|
|
135
|
+
type,
|
|
136
|
+
credential: { access_token: accessToken, refresh_token: refreshToken },
|
|
137
|
+
};
|
|
138
|
+
return { kind: "invalid" };
|
|
139
|
+
}
|
|
77
140
|
//# sourceMappingURL=supabase-session.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,yBAAyB,EAAuB,MAAM,uBAAuB,CAAC;AAmBpG
|
|
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.6",
|
|
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",
|
|
@@ -2,11 +2,13 @@ import {
|
|
|
2
2
|
AuthApiError,
|
|
3
3
|
AuthRefreshDiscardedError,
|
|
4
4
|
AuthRetryableFetchError,
|
|
5
|
+
AuthUnknownError,
|
|
5
6
|
} from "@supabase/supabase-js";
|
|
6
7
|
import { describe, expect, it, vi } from "vitest";
|
|
7
8
|
import {
|
|
8
9
|
createSupabaseSessionAdapter,
|
|
9
10
|
isAuthOutage,
|
|
11
|
+
parseAuthCallback,
|
|
10
12
|
type SupabaseSessionAuth,
|
|
11
13
|
} from "./supabase-session.ts";
|
|
12
14
|
|
|
@@ -130,4 +132,93 @@ describe("isAuthOutage", () => {
|
|
|
130
132
|
expect(isAuthOutage(new AuthApiError("Insufficient Storage", 507, undefined))).toBe(true);
|
|
131
133
|
expect(isAuthOutage(new AuthApiError("Network Connect Timeout", 599, undefined))).toBe(true);
|
|
132
134
|
});
|
|
135
|
+
|
|
136
|
+
it("reads nothing-went-wrong as nothing went wrong", () => {
|
|
137
|
+
// `null` is the SUCCESS value of every supabase-js auth call, and this function is
|
|
138
|
+
// exported, so the obvious `if (isAuthOutage(error))` around a `getUser()` result has to
|
|
139
|
+
// be safe. Before this, it answered true and put an outage on screen over every success.
|
|
140
|
+
// One adopter had already written the guard at its own call site, which is what a missing
|
|
141
|
+
// check in here looks like from the outside.
|
|
142
|
+
expect(isAuthOutage(null)).toBe(false);
|
|
143
|
+
expect(isAuthOutage(undefined)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("stays narrow for an error auth-js could not read", () => {
|
|
147
|
+
// AuthUnknownError is raised whenever the body does not parse as JSON, at ANY status, and
|
|
148
|
+
// it never fills one in — but `status` is still PRESENT, because the base class declares
|
|
149
|
+
// it, so `"status" in error` answers true and says nothing. That makes a 507 behind an
|
|
150
|
+
// HTML-answering proxy indistinguishable from a malformed 400, and the narrow answer is
|
|
151
|
+
// the right one: widening by class name would call the malformed 400 an outage too, and a
|
|
152
|
+
// dead session read as retryable is the same failure from the other side.
|
|
153
|
+
const unreadable = new AuthUnknownError("Unexpected token < in JSON", new Error("parse"));
|
|
154
|
+
expect(unreadable.status).toBeUndefined();
|
|
155
|
+
expect(isAuthOutage(unreadable)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("parseAuthCallback", () => {
|
|
160
|
+
it("reads an email link, including the types an app's templates may not send yet", () => {
|
|
161
|
+
expect(parseAuthCallback("?token_hash=pkce_abc&type=recovery")).toEqual({
|
|
162
|
+
kind: "email-link",
|
|
163
|
+
tokenHash: "pkce_abc",
|
|
164
|
+
type: "recovery",
|
|
165
|
+
});
|
|
166
|
+
// An operator can invite from Studio whatever the product does; rejecting the type put
|
|
167
|
+
// "invalid link" on a real, branded invitation.
|
|
168
|
+
expect(parseAuthCallback(new URLSearchParams("token_hash=abc&type=invite"))).toMatchObject({
|
|
169
|
+
kind: "email-link",
|
|
170
|
+
type: "invite",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("refuses an email link whose type is missing or made up", () => {
|
|
175
|
+
expect(parseAuthCallback("?token_hash=abc")).toEqual({ kind: "invalid" });
|
|
176
|
+
expect(parseAuthCallback("?token_hash=abc&type=admin")).toEqual({ kind: "invalid" });
|
|
177
|
+
// A key every object inherits is still not a link type.
|
|
178
|
+
expect(parseAuthCallback("?token_hash=abc&type=toString")).toEqual({ kind: "invalid" });
|
|
179
|
+
expect(parseAuthCallback("", "")).toEqual({ kind: "invalid" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("hands back the session a URL carries, in either flow", () => {
|
|
183
|
+
expect(parseAuthCallback("?code=4f1c")).toEqual({
|
|
184
|
+
kind: "session",
|
|
185
|
+
type: null,
|
|
186
|
+
credential: { code: "4f1c" },
|
|
187
|
+
});
|
|
188
|
+
// The implicit flow puts the tokens, and a recovery's type, in the fragment.
|
|
189
|
+
expect(
|
|
190
|
+
parseAuthCallback("", "#access_token=at&expires_in=3600&refresh_token=rt&type=recovery"),
|
|
191
|
+
).toEqual({
|
|
192
|
+
kind: "session",
|
|
193
|
+
type: "recovery",
|
|
194
|
+
credential: { access_token: "at", refresh_token: "rt" },
|
|
195
|
+
});
|
|
196
|
+
expect(parseAuthCallback("", "#access_token=at")).toEqual({ kind: "invalid" });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("tells a person who pressed Cancel from a link that expired", () => {
|
|
200
|
+
// A refusal relayed from the provider: GoTrue writes it to both halves, with no code.
|
|
201
|
+
const cancel = "error=access_denied&error_description=The+user+denied+the+request";
|
|
202
|
+
expect(parseAuthCallback(`?${cancel}`, `#${cancel}&sb=`)).toEqual({
|
|
203
|
+
kind: "error",
|
|
204
|
+
error: "access_denied",
|
|
205
|
+
code: null,
|
|
206
|
+
cancelled: true,
|
|
207
|
+
});
|
|
208
|
+
// GoTrue's own refusal of an implicit-flow email link: the SAME error, a code beside it,
|
|
209
|
+
// and only in the fragment — a query-only reader sees nothing at all.
|
|
210
|
+
expect(
|
|
211
|
+
parseAuthCallback(
|
|
212
|
+
"",
|
|
213
|
+
"#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired&sb=",
|
|
214
|
+
),
|
|
215
|
+
).toEqual({ kind: "error", error: "access_denied", code: "otp_expired", cancelled: false });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("reads a failure that carries no `error` at all", () => {
|
|
219
|
+
// Statuses outside GoTrue's OAuth mapping, a 429 among them, write only the code.
|
|
220
|
+
expect(
|
|
221
|
+
parseAuthCallback("", "#error_code=over_email_send_rate_limit&error_description=x&sb="),
|
|
222
|
+
).toEqual({ kind: "error", error: null, code: "over_email_send_rate_limit", cancelled: false });
|
|
223
|
+
});
|
|
133
224
|
});
|
package/src/supabase-session.ts
CHANGED
|
@@ -41,9 +41,16 @@ type _SupabaseAuthFitsAdapter = Satisfied<
|
|
|
41
41
|
* heading per RESOLVED version; `node_modules` keeps copies the resolver does not serve.
|
|
42
42
|
*
|
|
43
43
|
* Anything that is not an auth error at all answers true: a failure from outside auth-js is not
|
|
44
|
-
* a verdict on the credential either.
|
|
44
|
+
* a verdict on the credential either. Nothing at all answers false — `null` is what every
|
|
45
|
+
* supabase-js auth call returns on success, and success is not an outage.
|
|
45
46
|
*/
|
|
46
47
|
export function isAuthOutage(error: unknown): boolean {
|
|
48
|
+
// Nothing thrown is not an outage, and this clause exists because the function is exported.
|
|
49
|
+
// `null` is the SUCCESS value of every supabase-js auth call, so the obvious
|
|
50
|
+
// `if (isAuthOutage(error))` around a `getUser()` result has to be safe; without this it read
|
|
51
|
+
// every success as auth being down. An adopter had already written that guard at its own call
|
|
52
|
+
// site, which is what a check missing from in here looks like from the outside.
|
|
53
|
+
if (error == null) return false;
|
|
47
54
|
if (!isAuthError(error)) return true;
|
|
48
55
|
return isAuthRetryableFetchError(error) || (error.status ?? 0) >= 500;
|
|
49
56
|
}
|
|
@@ -90,3 +97,107 @@ export function createSupabaseSessionAdapter(auth: SupabaseSessionAuth): Session
|
|
|
90
97
|
},
|
|
91
98
|
};
|
|
92
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every link type a GoTrue email can carry — not only the ones this app's templates send today.
|
|
103
|
+
* One donor rejected `invite` because "invites are never sent", which was true of the product and
|
|
104
|
+
* false of the deployment: an operator inviting somebody from Studio sent a branded email whose
|
|
105
|
+
* link then said it was invalid. A type the templates never emit costs nothing to accept: the hash
|
|
106
|
+
* is the proof, and GoTrue opens a session only for the person whose one-time token it is.
|
|
107
|
+
*
|
|
108
|
+
* Closed on purpose: auth-js's own `EmailOtpType` ends in `(string & {})`, so it cannot make a
|
|
109
|
+
* destination map name every case. `Record<EmailLinkType, string>` can, and that is where an app
|
|
110
|
+
* decides that `recovery` and `invite` both continue to the new-password screen — GoTrue creates
|
|
111
|
+
* an invited account with NO password, so landing one "inside" signs them in once and locks them
|
|
112
|
+
* out after.
|
|
113
|
+
*/
|
|
114
|
+
export type EmailLinkType =
|
|
115
|
+
"signup" | "invite" | "magiclink" | "recovery" | "email_change" | "email";
|
|
116
|
+
|
|
117
|
+
const EMAIL_LINK_TYPES: Record<EmailLinkType, true> = {
|
|
118
|
+
signup: true,
|
|
119
|
+
invite: true,
|
|
120
|
+
magiclink: true,
|
|
121
|
+
recovery: true,
|
|
122
|
+
email_change: true,
|
|
123
|
+
email: true,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function isEmailLinkType(value: string): value is EmailLinkType {
|
|
127
|
+
// Not `in`: `"toString" in EMAIL_LINK_TYPES` is true, and the type is typed by whoever sends
|
|
128
|
+
// the link.
|
|
129
|
+
return Object.hasOwn(EMAIL_LINK_TYPES, value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Everything an auth callback address can carry, decided from the address alone. */
|
|
133
|
+
export type AuthCallback =
|
|
134
|
+
/** An email link with a one-use `token_hash`: trade it with `verifyOtp`, exactly once. */
|
|
135
|
+
| { kind: "email-link"; tokenHash: string; type: EmailLinkType }
|
|
136
|
+
/**
|
|
137
|
+
* A session arriving in the address: a PKCE `code`, or the implicit flow's tokens. In a browser
|
|
138
|
+
* with `detectSessionInUrl` on, the client has already taken it — wait for the session, and do
|
|
139
|
+
* not trade it again, which would be a second exchange racing the first. Where nothing reads
|
|
140
|
+
* the address for you (React Native), `credential` is what `exchangeCodeForSession` or
|
|
141
|
+
* `setSession` takes.
|
|
142
|
+
*/
|
|
143
|
+
| {
|
|
144
|
+
kind: "session";
|
|
145
|
+
type: EmailLinkType | null;
|
|
146
|
+
credential: { code: string } | { access_token: string; refresh_token: string };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* GoTrue, or the provider behind it, reported a failure in the address. `code` is GoTrue's
|
|
150
|
+
* stable `error_code` (`otp_expired`, `bad_oauth_state`, …) and is what to choose words by;
|
|
151
|
+
* `error_description` is left out on purpose — it is English-only and often names the wrong
|
|
152
|
+
* cause.
|
|
153
|
+
*/
|
|
154
|
+
| { kind: "error"; error: string | null; code: string | null; cancelled: boolean }
|
|
155
|
+
/** Nothing usable: cut short between the mail client and here, or edited by hand. */
|
|
156
|
+
| { kind: "invalid" };
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reads an auth callback address. Pure, so every edge case is testable without a router or a
|
|
160
|
+
* client — pass `location.search` and `location.hash`, or the two halves of a deep link.
|
|
161
|
+
*
|
|
162
|
+
* GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
|
|
163
|
+
* query too after a provider round trip, the fragment alone after an implicit-flow email link —
|
|
164
|
+
* so a callback reading one of the two misses a failure the other carries, and the person lands
|
|
165
|
+
* on a screen that says nothing. Both are read here.
|
|
166
|
+
*
|
|
167
|
+
* `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
|
|
168
|
+
* — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
|
|
169
|
+
* that same value for an expired email link, a banned user and a disabled signup, each with an
|
|
170
|
+
* `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
|
|
171
|
+
* test reads an expired link as a change of mind. And `error` itself is absent for a status
|
|
172
|
+
* outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
|
|
173
|
+
*/
|
|
174
|
+
export function parseAuthCallback(search: string | URLSearchParams, hash = ""): AuthCallback {
|
|
175
|
+
const query = new URLSearchParams(search);
|
|
176
|
+
const fragment = new URLSearchParams(hash.replace(/^#/, ""));
|
|
177
|
+
const read = (name: string) => query.get(name) || fragment.get(name) || null;
|
|
178
|
+
|
|
179
|
+
const error = read("error");
|
|
180
|
+
const code = read("error_code");
|
|
181
|
+
if (error || code || read("error_description"))
|
|
182
|
+
return { kind: "error", error, code, cancelled: error === "access_denied" && code === null };
|
|
183
|
+
|
|
184
|
+
const rawType = read("type") ?? "";
|
|
185
|
+
const type = isEmailLinkType(rawType) ? rawType : null;
|
|
186
|
+
|
|
187
|
+
const tokenHash = query.get("token_hash")?.trim();
|
|
188
|
+
if (tokenHash) return type ? { kind: "email-link", tokenHash, type } : { kind: "invalid" };
|
|
189
|
+
|
|
190
|
+
const pkceCode = query.get("code");
|
|
191
|
+
if (pkceCode) return { kind: "session", type, credential: { code: pkceCode } };
|
|
192
|
+
|
|
193
|
+
const accessToken = fragment.get("access_token");
|
|
194
|
+
const refreshToken = fragment.get("refresh_token");
|
|
195
|
+
if (accessToken && refreshToken)
|
|
196
|
+
return {
|
|
197
|
+
kind: "session",
|
|
198
|
+
type,
|
|
199
|
+
credential: { access_token: accessToken, refresh_token: refreshToken },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return { kind: "invalid" };
|
|
203
|
+
}
|