@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/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, warnIfStaleSession, type SSOClient } from "./index";
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> & { returnTo?: string },
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
- return authorize(session, access);
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="documentos: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="documentos: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
- }
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
+ }
@@ -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> = {