@dacyti-gsi/auth 0.2.0 → 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/package.json +1 -1
- package/src/config.ts +212 -212
- package/src/core.ts +4 -7
- package/src/index.ts +671 -673
- package/src/next.ts +19 -6
- package/src/policy.ts +102 -102
- package/src/react.tsx +171 -171
- package/src/session-cookie.ts +1 -3
- package/src/types.ts +0 -2
package/src/next.ts
CHANGED
|
@@ -2,14 +2,14 @@ import { cookies } from "next/headers";
|
|
|
2
2
|
import { redirect } from "next/navigation";
|
|
3
3
|
import { type NextRequest, NextResponse } from "next/server";
|
|
4
4
|
|
|
5
|
-
import { defineSSO,
|
|
6
|
-
import { bindComponents } from "./react";
|
|
5
|
+
import { defineSSO, type SSOClient, warnIfStaleSession } from "./index";
|
|
7
6
|
import {
|
|
8
7
|
type AccessPolicy,
|
|
9
8
|
type AuthorizationError,
|
|
10
9
|
authorize,
|
|
11
10
|
isAuthorizationError,
|
|
12
11
|
} from "./policy";
|
|
12
|
+
import { bindComponents } from "./react";
|
|
13
13
|
import type { SSOClientOptions, SSOSession } from "./types";
|
|
14
14
|
|
|
15
15
|
/** Cuándo una pantalla quiere permisos vivos en lugar de la foto de la cookie. */
|
|
@@ -52,10 +52,14 @@ export type NextSSO<Permission extends string> = SSOClient<Permission> & {
|
|
|
52
52
|
*
|
|
53
53
|
* Sin sesión redirige a autenticarse (con vuelta a la página actual cuando se
|
|
54
54
|
* le pasa `returnTo`). Sin permiso o sin segundo factor, lanza
|
|
55
|
-
* `SSOForbiddenError` / `SSOMfaRequiredError` para responder 403
|
|
55
|
+
* `SSOForbiddenError` / `SSOMfaRequiredError` para responder 403 —salvo que
|
|
56
|
+
* se pase `deniedRedirect`, que manda ahí al denegado en lugar de lanzar.
|
|
56
57
|
*/
|
|
57
58
|
protectPage(
|
|
58
|
-
policy?: AccessPolicy<Permission> & {
|
|
59
|
+
policy?: AccessPolicy<Permission> & {
|
|
60
|
+
returnTo?: string;
|
|
61
|
+
deniedRedirect?: string;
|
|
62
|
+
},
|
|
59
63
|
): Promise<SSOSession<Permission>>;
|
|
60
64
|
|
|
61
65
|
/**
|
|
@@ -242,10 +246,19 @@ export function defineNextSSO<const Permission extends string = string>(
|
|
|
242
246
|
},
|
|
243
247
|
|
|
244
248
|
async protectPage(policy = {}) {
|
|
245
|
-
const { returnTo, ...access } = policy;
|
|
249
|
+
const { returnTo, deniedRedirect, ...access } = policy;
|
|
246
250
|
const session = await resolve(access.revalidate ?? false);
|
|
247
251
|
if (!session) redirect(client.signInPath(returnTo));
|
|
248
|
-
|
|
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
|
+
}
|
|
249
262
|
},
|
|
250
263
|
|
|
251
264
|
action<Args extends unknown[], Out>(
|
package/src/policy.ts
CHANGED
|
@@ -1,102 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,171 +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="
|
|
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="
|
|
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
|
-
}
|
|
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
|
+
}
|
package/src/session-cookie.ts
CHANGED
|
@@ -4,13 +4,11 @@ import { EncryptJWT, jwtDecrypt } from "jose";
|
|
|
4
4
|
|
|
5
5
|
import { SSOConfigError } from "./errors";
|
|
6
6
|
|
|
7
|
-
/** Contenido que se guarda cifrado en la cookie. */
|
|
7
|
+
/** Contenido que se guarda cifrado en la cookie: identidad mínima + autorización. */
|
|
8
8
|
export type SessionPayload = {
|
|
9
9
|
sub: string;
|
|
10
10
|
email: string;
|
|
11
|
-
emailVerified: boolean;
|
|
12
11
|
name: string | null;
|
|
13
|
-
image: string | null;
|
|
14
12
|
roles: string[];
|
|
15
13
|
permissions: string[];
|
|
16
14
|
amr: string[];
|
package/src/types.ts
CHANGED
|
@@ -89,9 +89,7 @@ export type SSOUser = {
|
|
|
89
89
|
/** Identificador estable. Úsalo como clave, no el correo. */
|
|
90
90
|
id: string;
|
|
91
91
|
email: string;
|
|
92
|
-
emailVerified: boolean;
|
|
93
92
|
name: string | null;
|
|
94
|
-
image: string | null;
|
|
95
93
|
};
|
|
96
94
|
|
|
97
95
|
export type SSOSession<Permission extends string = string> = {
|