@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.
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { EncryptJWT, jwtDecrypt } from "jose";
4
+
5
+ import { SSOConfigError } from "./errors";
6
+
7
+ /** Contenido que se guarda cifrado en la cookie: identidad mínima + autorización. */
8
+ export type SessionPayload = {
9
+ sub: string;
10
+ email: string;
11
+ name: string | null;
12
+ roles: string[];
13
+ permissions: string[];
14
+ amr: string[];
15
+ accessToken: string;
16
+ refreshToken?: string;
17
+ /**
18
+ * El `id_token` de la sesión.
19
+ *
20
+ * Se guarda porque el cierre de sesión del portal lo exige: `end-session`
21
+ * necesita `id_token_hint` para saber qué sesión terminar, y sin él responde
22
+ * con un error y la sesión del portal sigue viva.
23
+ */
24
+ idToken?: string;
25
+ expiresAt: number;
26
+ };
27
+
28
+ /**
29
+ * Deriva la clave de cifrado a partir del secreto configurado.
30
+ *
31
+ * AES-256-GCM necesita exactamente 32 bytes, y un secreto escrito por una
32
+ * persona casi nunca los mide. Se pasa por SHA-256 para obtenerlos sin
33
+ * obligar a nadie a generar una clave con el tamaño justo.
34
+ */
35
+ function deriveKey(secret: string): Uint8Array {
36
+ if (secret.length < 32) {
37
+ throw new SSOConfigError(
38
+ "`secret` necesita al menos 32 caracteres. Genera uno con:\n" +
39
+ " node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"",
40
+ );
41
+ }
42
+
43
+ return new Uint8Array(createHash("sha256").update(secret).digest());
44
+ }
45
+
46
+ /**
47
+ * Cifra la sesión para guardarla en una cookie.
48
+ *
49
+ * Se cifra y no solo se firma porque el contenido incluye el access token. Una
50
+ * cookie firmada es legible por cualquiera que la tenga delante: un volcado de
51
+ * memoria, un log de proxy mal configurado o una copia del perfil del
52
+ * navegador bastarían para llevárselo.
53
+ */
54
+ export async function sealSession(
55
+ payload: SessionPayload,
56
+ secret: string,
57
+ maxAgeSeconds: number,
58
+ ): Promise<string> {
59
+ return new EncryptJWT({ ...payload })
60
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
61
+ .setIssuedAt()
62
+ .setExpirationTime(`${maxAgeSeconds}s`)
63
+ .encrypt(deriveKey(secret));
64
+ }
65
+
66
+ /**
67
+ * Descifra la cookie. Devuelve `null` si está corrupta, caducada o cifrada con
68
+ * otro secreto, en lugar de lanzar: para quien llama, todos esos casos
69
+ * significan lo mismo —no hay sesión utilizable— y tratarlos por separado solo
70
+ * añadiría ramas sin ninguna decisión distinta detrás.
71
+ */
72
+ export async function openSession(
73
+ token: string | undefined,
74
+ secret: string,
75
+ ): Promise<SessionPayload | null> {
76
+ if (!token) return null;
77
+
78
+ try {
79
+ const { payload } = await jwtDecrypt(token, deriveKey(secret));
80
+ return payload as unknown as SessionPayload;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Configuración del cliente.
3
+ *
4
+ * Todo tiene valor por defecto: lo que no se pasa se lee del entorno
5
+ * (`SSO_ISSUER`, `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, `SSO_SECRET`,
6
+ * `APP_URL`, `SSO_POST_LOGOUT_URI`). Cada opción que hay que rellenar para
7
+ * empezar es una decisión que se le pide a alguien que todavía no conoce el
8
+ * sistema; las variables de entorno ya son el idioma de despliegue.
9
+ *
10
+ * ```ts
11
+ * export const sso = defineNextSSO({ permissions: ["documentos:anular"] });
12
+ * ```
13
+ */
14
+ export type SSOClientOptions<Permission extends string = string> = {
15
+ /**
16
+ * URL del emisor, tal cual la publica el portal. Ej: `https://sso.ejemplo.com/api/auth`.
17
+ * Por defecto `SSO_ISSUER`.
18
+ */
19
+ issuer?: string;
20
+ /** Por defecto `SSO_CLIENT_ID`. */
21
+ clientId?: string;
22
+ /** Por defecto `SSO_CLIENT_SECRET`. */
23
+ clientSecret?: string;
24
+
25
+ /**
26
+ * Clave para cifrar la cookie de sesión. Mínimo 32 caracteres.
27
+ * Por defecto `SSO_SECRET`.
28
+ *
29
+ * La cookie guarda el access token. Cifrarla evita que acabe legible en un
30
+ * volcado de memoria, en un log de proxy o en una copia de seguridad del
31
+ * navegador.
32
+ */
33
+ secret?: string;
34
+
35
+ /**
36
+ * Espejo del catálogo que el portal declara para esta aplicación.
37
+ *
38
+ * El portal define los permisos (cadenas opacas: las almacena, las agrupa en
39
+ * roles y las emite como claim) y la aplicación les da significado al
40
+ * exigirlos con `can()`, `protectPage()`, `action()` o `<Protect>`. Esta
41
+ * lista no declara nada: si se pasa como tupla `as const`, `can()`
42
+ * autocompleta y un permiso mal escrito es un error de compilación en lugar
43
+ * de un `false` silencioso en producción.
44
+ *
45
+ * Declara primero en la consola del portal; lo que llegue sin declarar,
46
+ * simplemente nunca aparece en la sesión.
47
+ */
48
+ permissions?: readonly Permission[];
49
+
50
+ /** Ruta donde se montan los handlers. Por defecto `/api/sso`. */
51
+ basePath?: string;
52
+
53
+ /**
54
+ * URL pública de la aplicación consumidora. Solo explícita, nunca del
55
+ * entorno: el `redirect_uri` debe coincidir EXACTAMENTE con el registrado en
56
+ * el portal, y adivinarlo desde una variable global es como se acaba
57
+ * enviando el puerto equivocado.
58
+ *
59
+ * Opcional: se deduce de la petición entrante. Indícala si la aplicación vive
60
+ * detrás de un proxy que no reescribe `X-Forwarded-Host`, o si necesitas
61
+ * construir enlaces fuera del ciclo de una petición.
62
+ */
63
+ appUrl?: string;
64
+
65
+ /**
66
+ * A dónde vuelve el navegador tras cerrar la sesión en el portal.
67
+ * Por defecto `SSO_POST_LOGOUT_URI`.
68
+ *
69
+ * Por defecto, la raíz de la aplicación. **Tiene que estar registrada en el
70
+ * portal como URI de cierre de sesión** y coincidir exactamente: el endpoint
71
+ * de cierre solo redirige a URIs que un administrador dio de alta.
72
+ */
73
+ postLogoutRedirectUri?: string;
74
+
75
+ /** Por defecto `["openid", "profile", "email"]`. */
76
+ scopes?: string[];
77
+
78
+ /** Nombre de la cookie de sesión. Por defecto `__Host-sso_session`. */
79
+ cookieName?: string;
80
+
81
+ /** Duración de la sesión local, en segundos. Por defecto 7 días. */
82
+ sessionMaxAge?: number;
83
+ };
84
+
85
+ /** Métodos de autenticación, con los valores del RFC 8176. */
86
+ export type AuthMethod = "pwd" | "otp" | "swk" | "mfa" | (string & {});
87
+
88
+ export type SSOUser = {
89
+ /** Identificador estable. Úsalo como clave, no el correo. */
90
+ id: string;
91
+ email: string;
92
+ name: string | null;
93
+ };
94
+
95
+ export type SSOSession<Permission extends string = string> = {
96
+ user: SSOUser;
97
+
98
+ roles: string[];
99
+ permissions: Permission[];
100
+
101
+ /** Con qué se autenticó esta sesión. */
102
+ amr: AuthMethod[];
103
+
104
+ accessToken: string;
105
+ /** Marca de tiempo Unix en la que caduca el acceso. */
106
+ expiresAt: number;
107
+
108
+ /**
109
+ * Si el `access_token` ya caducó.
110
+ *
111
+ * Dura 15 minutos, muy poco frente a la vida de la cookie. Llamar a una API
112
+ * con un token caducado da 401, así que conviene consultarlo antes —o dejar
113
+ * que `session({ revalidate: true })` o `action()` lo renueven.
114
+ */
115
+ isExpired(): boolean;
116
+
117
+ /** Si la sesión tiene el permiso indicado. */
118
+ can(permission: Permission): boolean;
119
+ /** Si los tiene todos. */
120
+ canAll(...permissions: Permission[]): boolean;
121
+ /** Si tiene al menos uno. */
122
+ canAny(...permissions: Permission[]): boolean;
123
+
124
+ /** Lanza `SSOForbiddenError` si falta el permiso. */
125
+ requires(permission: Permission): void;
126
+
127
+ /** Si la autenticación usó más de un factor. */
128
+ usedSecondFactor(): boolean;
129
+ };
130
+
131
+ /** Documento de descubrimiento OIDC, con los campos que usa el cliente. */
132
+ export type DiscoveryDocument = {
133
+ issuer: string;
134
+ authorization_endpoint: string;
135
+ token_endpoint: string;
136
+ userinfo_endpoint: string;
137
+ jwks_uri: string;
138
+ end_session_endpoint?: string;
139
+ revocation_endpoint?: string;
140
+ introspection_endpoint?: string;
141
+ };