@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/LICENSE +202 -0
- package/README.md +419 -3
- package/package.json +71 -4
- package/src/config.ts +212 -0
- package/src/core.ts +448 -0
- package/src/errors.ts +53 -0
- package/src/index.ts +671 -0
- package/src/next.ts +309 -0
- package/src/pkce.ts +48 -0
- package/src/policy.ts +102 -0
- package/src/react.tsx +171 -0
- package/src/session-cookie.ts +84 -0
- package/src/types.ts +141 -0
|
@@ -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
|
+
};
|