@dacyti-gsi/auth 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/next.ts ADDED
@@ -0,0 +1,309 @@
1
+ import { cookies } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+ import { type NextRequest, NextResponse } from "next/server";
4
+
5
+ import { defineSSO, type SSOClient, warnIfStaleSession } from "./index";
6
+ import {
7
+ type AccessPolicy,
8
+ type AuthorizationError,
9
+ authorize,
10
+ isAuthorizationError,
11
+ } from "./policy";
12
+ import { bindComponents } from "./react";
13
+ import type { SSOClientOptions, SSOSession } from "./types";
14
+
15
+ /** Cuándo una pantalla quiere permisos vivos en lugar de la foto de la cookie. */
16
+ export type SessionOptions = {
17
+ /**
18
+ * Vuelve a preguntar al portal por la identidad y los permisos.
19
+ *
20
+ * Cuesta una ida y vuelta —y una renovación de token si el acceso caducó—, así
21
+ * que no se hace por defecto. A cambio, es lo que hace que revocar un rol se
22
+ * note de inmediato. Para operaciones que dependen de un permiso concreto, es
23
+ * la opción correcta aunque el camino rápido sea más barato.
24
+ */
25
+ revalidate?: boolean;
26
+ };
27
+
28
+ export type NextSSO<Permission extends string> = SSOClient<Permission> & {
29
+ /**
30
+ * Handlers para el route handler catch-all.
31
+ *
32
+ * ```ts
33
+ * // app/api/sso/[...sso]/route.ts
34
+ * export const { GET, POST } = sso.handlers();
35
+ * export const runtime = "nodejs";
36
+ * ```
37
+ */
38
+ handlers(): {
39
+ GET: (request: Request) => Promise<Response>;
40
+ POST: (request: Request) => Promise<Response>;
41
+ };
42
+
43
+ /** Sesión actual, o `null`. Nunca redirige ni lanza. */
44
+ session(options?: SessionOptions): Promise<SSOSession<Permission> | null>;
45
+
46
+ /**
47
+ * Guarda declarativo para páginas: exige la política.
48
+ *
49
+ * ```ts
50
+ * const session = await sso.protectPage({ permission: "documentos:anular", requireMfa: true });
51
+ * ```
52
+ *
53
+ * Sin sesión redirige a autenticarse (con vuelta a la página actual cuando se
54
+ * le pasa `returnTo`). Sin permiso o sin segundo factor, lanza
55
+ * `SSOForbiddenError` / `SSOMfaRequiredError` para responder 403 —salvo que
56
+ * se pase `deniedRedirect`, que manda ahí al denegado en lugar de lanzar.
57
+ */
58
+ protectPage(
59
+ policy?: AccessPolicy<Permission> & {
60
+ returnTo?: string;
61
+ deniedRedirect?: string;
62
+ },
63
+ ): Promise<SSOSession<Permission>>;
64
+
65
+ /**
66
+ * Envuelve una Server Action o mutación con la política exigida.
67
+ *
68
+ * Revalida y persiste por defecto: las escrituras no usan la foto de la
69
+ * cookie sino permisos vivos del portal. Pasa `{ revalidate: false }` solo
70
+ * para lecturas baratas.
71
+ *
72
+ * ```ts
73
+ * export const anular = sso.action("documentos:anular", async (session, id: string) => { … });
74
+ * ```
75
+ *
76
+ * Sin sesión, sin permiso o sin segundo factor, lanza por defecto
77
+ * (`SSOAuthRequiredError`, `SSOForbiddenError`, `SSOMfaRequiredError`): lo
78
+ * correcto para páginas y rutas, donde el error lo atiende `error.tsx` o un
79
+ * 403. Para formularios con `useActionState`, pasa `onDenied` y la
80
+ * denegación se convierte en valor en lugar de reventar la acción.
81
+ *
82
+ * ```ts
83
+ * export const anular = sso.action("documentos:anular", run, {
84
+ * onDenied: () => ({ ok: false as const, message: "Sin permiso." }),
85
+ * });
86
+ * ```
87
+ */
88
+ action<Args extends unknown[], Out>(
89
+ policy: Permission | AccessPolicy<Permission>,
90
+ fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
91
+ options?: {
92
+ onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
93
+ },
94
+ ): (...args: Args) => Promise<Out>;
95
+
96
+ /**
97
+ * URL que renueva la sesión y vuelve.
98
+ *
99
+ * Se usa como destino de un enlace o una navegación. Al pasar por un route
100
+ * handler, la cookie renovada sí se persiste.
101
+ */
102
+ refreshPath(returnTo?: string): string;
103
+
104
+ /**
105
+ * Renueva el acceso caducado ANTES de renderizar y deja la cookie nueva en la respuesta.
106
+ *
107
+ * Los Server Components no pueden escribir cookies: sin esto, pasados los 15 minutos de acceso
108
+ * cada render vuelve a canjear el refresh token (dos llamadas al portal por página) y, si el
109
+ * portal rota los tokens, acaba fallando. Se monta una vez por app:
110
+ *
111
+ * ```ts
112
+ * // proxy.ts
113
+ * import { sso } from "@/lib/sso";
114
+ * export const proxy = sso.proxy;
115
+ * export const config = { matcher: ["/((?!_next/|api/sso/|favicon.ico).*)"] };
116
+ * ```
117
+ *
118
+ * Si el portal no responde no hace nada: la página decide con lo que tenga.
119
+ */
120
+ proxy(request: NextRequest): Promise<NextResponse>;
121
+
122
+ /**
123
+ * Componentes de React ya atados a este cliente y con los permisos tipados:
124
+ * `Can`, `Protect`, `SignedIn`, `SignedOut`, `SignInLink`, `SignOutLink`.
125
+ */
126
+ components: ReturnType<typeof bindComponents<Permission>>;
127
+ };
128
+
129
+ /**
130
+ * Declara el cliente del portal con las piezas de Next.js ya montadas.
131
+ *
132
+ * ```ts
133
+ * // lib/sso.ts
134
+ * export const sso = defineNextSSO({
135
+ * permissions: ["documentos:crear", "documentos:anular"],
136
+ * });
137
+ * // El resto sale del entorno: SSO_ISSUER, SSO_CLIENT_ID,
138
+ * // SSO_CLIENT_SECRET, SSO_SECRET, SSO_POST_LOGOUT_URI.
139
+ * ```
140
+ */
141
+ export function defineNextSSO<const Permission extends string = string>(
142
+ options: SSOClientOptions<Permission>,
143
+ ): NextSSO<Permission> {
144
+ const client = defineSSO<Permission>(options);
145
+
146
+ /**
147
+ * En producción las cookies van con `Secure`; en desarrollo por HTTP, no.
148
+ * `appUrl` es la fuente más fiable: el servidor de Next.js no ve el protocolo
149
+ * del navegador desde aquí.
150
+ */
151
+ const secure = (options.appUrl ?? "").startsWith("https://")
152
+ ? true
153
+ : process.env.NODE_ENV === "production";
154
+
155
+ async function handle(request: Request): Promise<Response> {
156
+ const response = await client.handleRequest(request);
157
+
158
+ return (
159
+ response ??
160
+ new Response("Ruta del cliente SSO desconocida.", { status: 404 })
161
+ );
162
+ }
163
+
164
+ async function cookieValue(): Promise<string | undefined> {
165
+ const store = await cookies();
166
+
167
+ return store.get(client.cookieName)?.value;
168
+ }
169
+
170
+ /**
171
+ * Resuelve la sesión: camino rápido local, o contra el portal con
172
+ * `revalidate`. Un fallo del portal no cierra la sesión de inmediato: cae a
173
+ * la foto local, salvo que el acceso llevara caducado más de
174
+ * `MAX_STALE_SECONDS` (ver `warnIfStaleSession`).
175
+ */
176
+ async function resolve(
177
+ revalidate: boolean,
178
+ ): Promise<SSOSession<Permission> | null> {
179
+ try {
180
+ const resolved = await client.resolveSession(await cookieValue(), {
181
+ revalidate,
182
+ });
183
+ // Donde se puede escribir (Server Actions, route handlers) se guarda la renovación;
184
+ // en un Server Component `cookies().set` lanza y la renueva `proxy()`.
185
+ await persist(resolved?.cookie ?? null).catch(() => {});
186
+ return resolved?.session ?? null;
187
+ } catch {
188
+ // El núcleo ya aplica la ventana de gracia al revalidar; aquí solo
189
+ // queda la foto de la cookie. Si también está caducada hace más de la
190
+ // ventana, no se sirve: una caída del portal no es barra libre.
191
+ const session = await client.readSession(await cookieValue());
192
+ if (session && warnIfStaleSession(session.expiresAt)) return null;
193
+ return session;
194
+ }
195
+ }
196
+
197
+ async function persist(cookie: string | null): Promise<void> {
198
+ if (!cookie) return;
199
+ const store = await cookies();
200
+ store.set(client.cookieName, cookie, {
201
+ path: "/",
202
+ httpOnly: true,
203
+ sameSite: "lax",
204
+ secure,
205
+ maxAge: client.sessionMaxAge,
206
+ });
207
+ }
208
+
209
+ const self: Omit<NextSSO<Permission>, "components"> = {
210
+ ...client,
211
+
212
+ handlers: () => ({ GET: handle, POST: handle }),
213
+
214
+ session: (sessionOptions = {}) =>
215
+ resolve(sessionOptions.revalidate ?? false),
216
+
217
+ async proxy(request) {
218
+ const current = request.cookies.get(client.cookieName)?.value;
219
+ if (!current) return NextResponse.next();
220
+
221
+ let resolved: Awaited<ReturnType<typeof client.resolveSession>>;
222
+ try {
223
+ resolved = await client.resolveSession(current);
224
+ } catch {
225
+ return NextResponse.next();
226
+ }
227
+ if (!resolved?.cookie) return NextResponse.next();
228
+
229
+ // La página de esta misma petición debe ver la cookie nueva, no la caducada.
230
+ request.cookies.set(client.cookieName, resolved.cookie);
231
+ const response = NextResponse.next({ request });
232
+ response.cookies.set(client.cookieName, resolved.cookie, {
233
+ path: "/",
234
+ httpOnly: true,
235
+ sameSite: "lax",
236
+ secure,
237
+ maxAge: client.sessionMaxAge,
238
+ });
239
+ return response;
240
+ },
241
+
242
+ refreshPath(returnTo) {
243
+ return returnTo
244
+ ? `${client.basePath}/refresh?to=${encodeURIComponent(returnTo)}`
245
+ : `${client.basePath}/refresh`;
246
+ },
247
+
248
+ async protectPage(policy = {}) {
249
+ const { returnTo, deniedRedirect, ...access } = policy;
250
+ const session = await resolve(access.revalidate ?? false);
251
+ if (!session) redirect(client.signInPath(returnTo));
252
+ try {
253
+ return authorize(session, access);
254
+ } catch (error) {
255
+ // Denegación de la política (no fallo del portal): con destino
256
+ // declarado, al aviso en lugar de al 403 seco.
257
+ if (deniedRedirect && isAuthorizationError(error)) {
258
+ redirect(deniedRedirect);
259
+ }
260
+ throw error;
261
+ }
262
+ },
263
+
264
+ action<Args extends unknown[], Out>(
265
+ this: void,
266
+ policy: Permission | AccessPolicy<Permission>,
267
+ fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
268
+ options?: {
269
+ onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
270
+ },
271
+ ): (...args: Args) => Promise<Out> {
272
+ const access: AccessPolicy<Permission> =
273
+ typeof policy === "string"
274
+ ? { permission: policy, revalidate: true }
275
+ : { revalidate: true, ...policy };
276
+ return (async (...args: Args) => {
277
+ const resolved = await client.resolveSession(await cookieValue(), {
278
+ revalidate: access.revalidate ?? true,
279
+ });
280
+ let session: SSOSession<Permission>;
281
+ try {
282
+ session = authorize(resolved?.session ?? null, access);
283
+ } catch (error: unknown) {
284
+ // Solo las denegaciones de la política se convierten en valor, y
285
+ // solo si se pidió: un fallo del portal sigue lanzando.
286
+ if (options?.onDenied && isAuthorizationError(error)) {
287
+ return options.onDenied(error);
288
+ }
289
+ throw error;
290
+ }
291
+ await persist(resolved?.cookie ?? null);
292
+ return fn(session, ...args);
293
+ }) as (...args: Args) => Promise<Out>;
294
+ },
295
+ };
296
+
297
+ return { ...self, components: bindComponents<Permission>(self) };
298
+ }
299
+
300
+ export { SSOForbiddenError } from "./errors";
301
+ export {
302
+ type AccessPolicy,
303
+ type AuthorizationError,
304
+ authorize,
305
+ isAuthorizationError,
306
+ SSOAuthRequiredError,
307
+ SSOMfaRequiredError,
308
+ } from "./policy";
309
+ export type { SSOSession, SSOUser } from "./types";
package/src/pkce.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ function base64url(input: Buffer): string {
4
+ return input.toString("base64url");
5
+ }
6
+
7
+ export type AuthorizationAttempt = {
8
+ /** Se guarda en la cookie temporal y se envía al canjear el código. */
9
+ verifier: string;
10
+ /** Viaja en la URL de autorización. */
11
+ challenge: string;
12
+ /** Debe volver intacto; es la protección contra CSRF en el redirect. */
13
+ state: string;
14
+ };
15
+
16
+ /**
17
+ * Prepara un intento de autorización con PKCE.
18
+ *
19
+ * El verifier son 48 bytes aleatorios; el challenge es su SHA-256. El portal
20
+ * exige `S256` para todos los clientes, también los confidenciales: sin PKCE,
21
+ * un código interceptado en el redirect puede canjearse desde otro sitio.
22
+ */
23
+ export function createAuthorizationAttempt(): AuthorizationAttempt {
24
+ const verifier = base64url(randomBytes(48));
25
+
26
+ return {
27
+ verifier,
28
+ challenge: base64url(createHash("sha256").update(verifier).digest()),
29
+ state: base64url(randomBytes(16)),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Compara dos `state` sin filtrar información por el tiempo de comparación.
35
+ *
36
+ * Una comparación normal con `===` sale antes en el primer carácter distinto,
37
+ * lo que permitiría adivinar el valor byte a byte cronometrando.
38
+ */
39
+ export function statesMatch(expected: string, received: string): boolean {
40
+ if (expected.length !== received.length) return false;
41
+
42
+ let difference = 0;
43
+ for (let index = 0; index < expected.length; index += 1) {
44
+ difference |= expected.charCodeAt(index) ^ received.charCodeAt(index);
45
+ }
46
+
47
+ return difference === 0;
48
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { SSOError, SSOForbiddenError } from "./errors";
2
+ import type { SSOSession } from "./types";
3
+
4
+ /** La sesión terminó o nunca existió: hay que mandar a autenticarse. */
5
+ export class SSOAuthRequiredError extends SSOError {
6
+ constructor(message = "Se requiere sesión.") {
7
+ super(message);
8
+ }
9
+ }
10
+
11
+ /** Hay sesión pero sin segundo factor, y la política lo exige. */
12
+ export class SSOMfaRequiredError extends SSOError {
13
+ constructor(message = "Esta operación requiere verificación en dos pasos.") {
14
+ super(message);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Denegación de la política: sin sesión, sin permiso o sin segundo factor.
20
+ *
21
+ * Son los únicos errores que `onDenied` convierte en valor. Un fallo del
22
+ * portal (`SSOProtocolError`) o de configuración nunca se tragan: una caída
23
+ * no es una denegación y tratarla como tal echaría a usuarios por un 502.
24
+ */
25
+ export type AuthorizationError =
26
+ | SSOAuthRequiredError
27
+ | SSOForbiddenError
28
+ | SSOMfaRequiredError;
29
+
30
+ export function isAuthorizationError(
31
+ error: unknown,
32
+ ): error is AuthorizationError {
33
+ return (
34
+ error instanceof SSOAuthRequiredError ||
35
+ error instanceof SSOForbiddenError ||
36
+ error instanceof SSOMfaRequiredError
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Política de acceso declarativa. Todo deniega por defecto: solo se permite lo
42
+ * que se declara.
43
+ */
44
+ export type AccessPolicy<Permission extends string = string> = {
45
+ /** Permiso exigido. Sin sesión o sin permiso, se deniega. */
46
+ permission?: Permission | readonly Permission[];
47
+ /** Exige que todos los permisos listados estén presentes. Por defecto basta uno. */
48
+ mode?: "any" | "all";
49
+ /**
50
+ * Exige tener al menos un permiso de esta aplicación, cualquiera.
51
+ *
52
+ * El portal autentica a cualquier cuenta: haber entrado no significa tener acceso a
53
+ * esta app. Con `access: true` quien llega sin ningún permiso queda fuera (403), sin
54
+ * tener que escoger un permiso «de entrada» arbitrario.
55
+ */
56
+ access?: boolean;
57
+ /** Exige segundo factor (`amr` incluye `mfa`). */
58
+ requireMfa?: boolean;
59
+ /** Vuelve a preguntar al portal por permisos vivos. */
60
+ revalidate?: boolean;
61
+ };
62
+
63
+ /**
64
+ * Decide si una sesión cumple la política. No habla con la red: la frescura la
65
+ * decide quien llama (`revalidate` en `protect`/`action`).
66
+ *
67
+ * Lanza `SSOForbiddenError`, `SSOMfaRequiredError` o `SSOAuthRequiredError`.
68
+ * Devuelve la sesión para encadenar.
69
+ */
70
+ export function authorize<Permission extends string>(
71
+ session: SSOSession<Permission> | null,
72
+ policy: AccessPolicy<Permission> = {},
73
+ ): SSOSession<Permission> {
74
+ if (!session) throw new SSOAuthRequiredError();
75
+
76
+ if (policy.access && session.permissions.length === 0) {
77
+ throw new SSOForbiddenError("acceso");
78
+ }
79
+
80
+ const wanted = policy.permission
81
+ ? Array.isArray(policy.permission)
82
+ ? [...policy.permission]
83
+ : [policy.permission]
84
+ : [];
85
+
86
+ if (wanted.length > 0) {
87
+ const ok =
88
+ policy.mode === "all"
89
+ ? session.canAll(...wanted)
90
+ : session.canAny(...wanted);
91
+ if (!ok) {
92
+ const missing = wanted.find((p) => !session.can(p)) ?? wanted[0];
93
+ session.requires(missing as Permission);
94
+ }
95
+ }
96
+
97
+ if (policy.requireMfa && !session.usedSecondFactor()) {
98
+ throw new SSOMfaRequiredError();
99
+ }
100
+
101
+ return session;
102
+ }
package/src/react.tsx ADDED
@@ -0,0 +1,171 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+
3
+ import { type AccessPolicy, authorize } from "./policy";
4
+ import type { SSOSession } from "./types";
5
+
6
+ /** Lo mínimo que un componente necesita: resolver la sesión actual. */
7
+ export type SessionResolver<Permission extends string> = {
8
+ session(options?: {
9
+ revalidate?: boolean;
10
+ }): Promise<SSOSession<Permission> | null>;
11
+ };
12
+
13
+ export type ProtectProps<Permission extends string> =
14
+ AccessPolicy<Permission> & {
15
+ /** El cliente Next (`sso`). Explícito a propósito: sin contexto global que heredar. */
16
+ sso: SessionResolver<Permission>;
17
+ children: ReactNode;
18
+ /** Qué pintar cuando se deniega. Por defecto no pinta nada (niega por defecto). */
19
+ fallback?: ReactNode;
20
+ };
21
+
22
+ /**
23
+ * Guarda declarativa para React Server Components.
24
+ *
25
+ * ```tsx
26
+ * <Protect sso={sso} permission="pedidos:anular" fallback={<p>Sin acceso.</p>}>
27
+ * <BotonAnular />
28
+ * </Protect>
29
+ * ```
30
+ *
31
+ * Nunca redirige ni lanza: denegar pinta `fallback` (o nada). Para páginas
32
+ * completas usa `sso.protectPage()`, que sí redirige a entrar.
33
+ */
34
+ export async function Protect<Permission extends string>({
35
+ sso,
36
+ children,
37
+ fallback = null,
38
+ ...policy
39
+ }: ProtectProps<Permission>): Promise<ReactNode> {
40
+ const session = await sso.session({ revalidate: policy.revalidate ?? false });
41
+
42
+ try {
43
+ authorize(session, policy);
44
+ return children;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ export type CanProps<Permission extends string> = AccessPolicy<Permission> & {
51
+ sso: SessionResolver<Permission>;
52
+ children: ReactNode;
53
+ fallback?: ReactNode;
54
+ };
55
+
56
+ /**
57
+ * Alias de `Protect` para permisos puntuales en la interfaz. Misma negación
58
+ * por defecto: sin permiso, `fallback` o nada.
59
+ */
60
+ export async function Can<Permission extends string>(
61
+ props: CanProps<Permission>,
62
+ ): Promise<ReactNode> {
63
+ return Protect(props);
64
+ }
65
+
66
+ /** Lo mínimo para pintar enlaces de entrada y salida. */
67
+ export type LinkResolver = {
68
+ signInPath(returnTo?: string): string;
69
+ signOutPath(): string;
70
+ };
71
+
72
+ type AnchorRest = Omit<ComponentProps<"a">, "href">;
73
+
74
+ export type SignInLinkProps = AnchorRest & {
75
+ /** El cliente Next (`sso`). */
76
+ sso: LinkResolver;
77
+ /** A dónde volver tras entrar. Por defecto, la raíz. */
78
+ returnTo?: string;
79
+ };
80
+
81
+ /**
82
+ * Enlace de entrada.
83
+ *
84
+ * ```tsx
85
+ * <SignInLink sso={sso} returnTo="/panel" className="boton">
86
+ * Entrar con el Portal SSO
87
+ * </SignInLink>
88
+ * ```
89
+ */
90
+ export function SignInLink({
91
+ sso,
92
+ returnTo,
93
+ children = "Entrar",
94
+ ...anchor
95
+ }: SignInLinkProps): ReactNode {
96
+ return (
97
+ <a {...anchor} href={sso.signInPath(returnTo)}>
98
+ {children}
99
+ </a>
100
+ );
101
+ }
102
+
103
+ export type SignOutLinkProps = AnchorRest & {
104
+ /** El cliente Next (`sso`). */
105
+ sso: LinkResolver;
106
+ };
107
+
108
+ /**
109
+ * Enlace de salida. Cierra la sesión local **y** la del portal: salir solo de
110
+ * la local dejaría a la persona dentro del portal y el siguiente entrar la
111
+ * devolvería sin pedir nada.
112
+ */
113
+ export function SignOutLink({
114
+ sso,
115
+ children = "Salir",
116
+ ...anchor
117
+ }: SignOutLinkProps): ReactNode {
118
+ return (
119
+ <a {...anchor} href={sso.signOutPath()}>
120
+ {children}
121
+ </a>
122
+ );
123
+ }
124
+
125
+ type BoundProps<Permission extends string> = AccessPolicy<Permission> & {
126
+ children: ReactNode;
127
+ fallback?: ReactNode;
128
+ };
129
+
130
+ /**
131
+ * Los componentes ya atados a un cliente: sin pasar `sso` en cada uso y con el catálogo de
132
+ * permisos tipado (una errata en `permission` no compila).
133
+ *
134
+ * ```tsx
135
+ * // lib/sso.ts
136
+ * export const { Can, SignedIn, SignedOut, SignInLink, SignOutLink } = sso.components;
137
+ *
138
+ * <SignedOut><SignInLink returnTo="/panel" /></SignedOut>
139
+ * <SignedIn><SignOutLink /></SignedIn>
140
+ * <Can permission="pedidos:anular"><BotonAnular /></Can>
141
+ * ```
142
+ */
143
+ export function bindComponents<Permission extends string>(
144
+ sso: SessionResolver<Permission> & LinkResolver,
145
+ ) {
146
+ return {
147
+ /** Pinta los hijos si se cumple la política; si no, `fallback` o nada. */
148
+ Can: (props: BoundProps<Permission>) => Protect({ ...props, sso }),
149
+ /** Igual que `Can`; el nombre que lee mejor para bloques completos. */
150
+ Protect: (props: BoundProps<Permission>) => Protect({ ...props, sso }),
151
+
152
+ /** Solo con sesión. */
153
+ SignedIn: async ({
154
+ children,
155
+ }: {
156
+ children: ReactNode;
157
+ }): Promise<ReactNode> => ((await sso.session()) ? children : null),
158
+
159
+ /** Solo sin sesión. */
160
+ SignedOut: async ({
161
+ children,
162
+ }: {
163
+ children: ReactNode;
164
+ }): Promise<ReactNode> => ((await sso.session()) ? null : children),
165
+
166
+ SignInLink: (props: Omit<SignInLinkProps, "sso">) =>
167
+ SignInLink({ ...props, sso }),
168
+ SignOutLink: (props: Omit<SignOutLinkProps, "sso">) =>
169
+ SignOutLink({ ...props, sso }),
170
+ };
171
+ }