@dacyti-gsi/auth 0.0.0-stage → 0.2.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 +451 -0
- package/src/errors.ts +53 -0
- package/src/index.ts +673 -0
- package/src/next.ts +296 -0
- package/src/pkce.ts +48 -0
- package/src/policy.ts +102 -0
- package/src/react.tsx +171 -0
- package/src/session-cookie.ts +86 -0
- package/src/types.ts +143 -0
package/src/next.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { cookies } from "next/headers";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
4
|
+
|
|
5
|
+
import { defineSSO, warnIfStaleSession, type SSOClient } from "./index";
|
|
6
|
+
import { bindComponents } from "./react";
|
|
7
|
+
import {
|
|
8
|
+
type AccessPolicy,
|
|
9
|
+
type AuthorizationError,
|
|
10
|
+
authorize,
|
|
11
|
+
isAuthorizationError,
|
|
12
|
+
} from "./policy";
|
|
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.
|
|
56
|
+
*/
|
|
57
|
+
protectPage(
|
|
58
|
+
policy?: AccessPolicy<Permission> & { returnTo?: string },
|
|
59
|
+
): Promise<SSOSession<Permission>>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Envuelve una Server Action o mutación con la política exigida.
|
|
63
|
+
*
|
|
64
|
+
* Revalida y persiste por defecto: las escrituras no usan la foto de la
|
|
65
|
+
* cookie sino permisos vivos del portal. Pasa `{ revalidate: false }` solo
|
|
66
|
+
* para lecturas baratas.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* export const anular = sso.action("documentos:anular", async (session, id: string) => { … });
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* Sin sesión, sin permiso o sin segundo factor, lanza por defecto
|
|
73
|
+
* (`SSOAuthRequiredError`, `SSOForbiddenError`, `SSOMfaRequiredError`): lo
|
|
74
|
+
* correcto para páginas y rutas, donde el error lo atiende `error.tsx` o un
|
|
75
|
+
* 403. Para formularios con `useActionState`, pasa `onDenied` y la
|
|
76
|
+
* denegación se convierte en valor en lugar de reventar la acción.
|
|
77
|
+
*
|
|
78
|
+
* ```ts
|
|
79
|
+
* export const anular = sso.action("documentos:anular", run, {
|
|
80
|
+
* onDenied: () => ({ ok: false as const, message: "Sin permiso." }),
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
action<Args extends unknown[], Out>(
|
|
85
|
+
policy: Permission | AccessPolicy<Permission>,
|
|
86
|
+
fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
|
|
87
|
+
options?: {
|
|
88
|
+
onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
|
|
89
|
+
},
|
|
90
|
+
): (...args: Args) => Promise<Out>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* URL que renueva la sesión y vuelve.
|
|
94
|
+
*
|
|
95
|
+
* Se usa como destino de un enlace o una navegación. Al pasar por un route
|
|
96
|
+
* handler, la cookie renovada sí se persiste.
|
|
97
|
+
*/
|
|
98
|
+
refreshPath(returnTo?: string): string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Renueva el acceso caducado ANTES de renderizar y deja la cookie nueva en la respuesta.
|
|
102
|
+
*
|
|
103
|
+
* Los Server Components no pueden escribir cookies: sin esto, pasados los 15 minutos de acceso
|
|
104
|
+
* cada render vuelve a canjear el refresh token (dos llamadas al portal por página) y, si el
|
|
105
|
+
* portal rota los tokens, acaba fallando. Se monta una vez por app:
|
|
106
|
+
*
|
|
107
|
+
* ```ts
|
|
108
|
+
* // proxy.ts
|
|
109
|
+
* import { sso } from "@/lib/sso";
|
|
110
|
+
* export const proxy = sso.proxy;
|
|
111
|
+
* export const config = { matcher: ["/((?!_next/|api/sso/|favicon.ico).*)"] };
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* Si el portal no responde no hace nada: la página decide con lo que tenga.
|
|
115
|
+
*/
|
|
116
|
+
proxy(request: NextRequest): Promise<NextResponse>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Componentes de React ya atados a este cliente y con los permisos tipados:
|
|
120
|
+
* `Can`, `Protect`, `SignedIn`, `SignedOut`, `SignInLink`, `SignOutLink`.
|
|
121
|
+
*/
|
|
122
|
+
components: ReturnType<typeof bindComponents<Permission>>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Declara el cliente del portal con las piezas de Next.js ya montadas.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* // lib/sso.ts
|
|
130
|
+
* export const sso = defineNextSSO({
|
|
131
|
+
* permissions: ["documentos:crear", "documentos:anular"],
|
|
132
|
+
* });
|
|
133
|
+
* // El resto sale del entorno: SSO_ISSUER, SSO_CLIENT_ID,
|
|
134
|
+
* // SSO_CLIENT_SECRET, SSO_SECRET, SSO_POST_LOGOUT_URI.
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export function defineNextSSO<const Permission extends string = string>(
|
|
138
|
+
options: SSOClientOptions<Permission>,
|
|
139
|
+
): NextSSO<Permission> {
|
|
140
|
+
const client = defineSSO<Permission>(options);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* En producción las cookies van con `Secure`; en desarrollo por HTTP, no.
|
|
144
|
+
* `appUrl` es la fuente más fiable: el servidor de Next.js no ve el protocolo
|
|
145
|
+
* del navegador desde aquí.
|
|
146
|
+
*/
|
|
147
|
+
const secure = (options.appUrl ?? "").startsWith("https://")
|
|
148
|
+
? true
|
|
149
|
+
: process.env.NODE_ENV === "production";
|
|
150
|
+
|
|
151
|
+
async function handle(request: Request): Promise<Response> {
|
|
152
|
+
const response = await client.handleRequest(request);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
response ??
|
|
156
|
+
new Response("Ruta del cliente SSO desconocida.", { status: 404 })
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function cookieValue(): Promise<string | undefined> {
|
|
161
|
+
const store = await cookies();
|
|
162
|
+
|
|
163
|
+
return store.get(client.cookieName)?.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resuelve la sesión: camino rápido local, o contra el portal con
|
|
168
|
+
* `revalidate`. Un fallo del portal no cierra la sesión de inmediato: cae a
|
|
169
|
+
* la foto local, salvo que el acceso llevara caducado más de
|
|
170
|
+
* `MAX_STALE_SECONDS` (ver `warnIfStaleSession`).
|
|
171
|
+
*/
|
|
172
|
+
async function resolve(
|
|
173
|
+
revalidate: boolean,
|
|
174
|
+
): Promise<SSOSession<Permission> | null> {
|
|
175
|
+
try {
|
|
176
|
+
const resolved = await client.resolveSession(await cookieValue(), {
|
|
177
|
+
revalidate,
|
|
178
|
+
});
|
|
179
|
+
// Donde se puede escribir (Server Actions, route handlers) se guarda la renovación;
|
|
180
|
+
// en un Server Component `cookies().set` lanza y la renueva `proxy()`.
|
|
181
|
+
await persist(resolved?.cookie ?? null).catch(() => {});
|
|
182
|
+
return resolved?.session ?? null;
|
|
183
|
+
} catch {
|
|
184
|
+
// El núcleo ya aplica la ventana de gracia al revalidar; aquí solo
|
|
185
|
+
// queda la foto de la cookie. Si también está caducada hace más de la
|
|
186
|
+
// ventana, no se sirve: una caída del portal no es barra libre.
|
|
187
|
+
const session = await client.readSession(await cookieValue());
|
|
188
|
+
if (session && warnIfStaleSession(session.expiresAt)) return null;
|
|
189
|
+
return session;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function persist(cookie: string | null): Promise<void> {
|
|
194
|
+
if (!cookie) return;
|
|
195
|
+
const store = await cookies();
|
|
196
|
+
store.set(client.cookieName, cookie, {
|
|
197
|
+
path: "/",
|
|
198
|
+
httpOnly: true,
|
|
199
|
+
sameSite: "lax",
|
|
200
|
+
secure,
|
|
201
|
+
maxAge: client.sessionMaxAge,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const self: Omit<NextSSO<Permission>, "components"> = {
|
|
206
|
+
...client,
|
|
207
|
+
|
|
208
|
+
handlers: () => ({ GET: handle, POST: handle }),
|
|
209
|
+
|
|
210
|
+
session: (sessionOptions = {}) =>
|
|
211
|
+
resolve(sessionOptions.revalidate ?? false),
|
|
212
|
+
|
|
213
|
+
async proxy(request) {
|
|
214
|
+
const current = request.cookies.get(client.cookieName)?.value;
|
|
215
|
+
if (!current) return NextResponse.next();
|
|
216
|
+
|
|
217
|
+
let resolved: Awaited<ReturnType<typeof client.resolveSession>>;
|
|
218
|
+
try {
|
|
219
|
+
resolved = await client.resolveSession(current);
|
|
220
|
+
} catch {
|
|
221
|
+
return NextResponse.next();
|
|
222
|
+
}
|
|
223
|
+
if (!resolved?.cookie) return NextResponse.next();
|
|
224
|
+
|
|
225
|
+
// La página de esta misma petición debe ver la cookie nueva, no la caducada.
|
|
226
|
+
request.cookies.set(client.cookieName, resolved.cookie);
|
|
227
|
+
const response = NextResponse.next({ request });
|
|
228
|
+
response.cookies.set(client.cookieName, resolved.cookie, {
|
|
229
|
+
path: "/",
|
|
230
|
+
httpOnly: true,
|
|
231
|
+
sameSite: "lax",
|
|
232
|
+
secure,
|
|
233
|
+
maxAge: client.sessionMaxAge,
|
|
234
|
+
});
|
|
235
|
+
return response;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
refreshPath(returnTo) {
|
|
239
|
+
return returnTo
|
|
240
|
+
? `${client.basePath}/refresh?to=${encodeURIComponent(returnTo)}`
|
|
241
|
+
: `${client.basePath}/refresh`;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async protectPage(policy = {}) {
|
|
245
|
+
const { returnTo, ...access } = policy;
|
|
246
|
+
const session = await resolve(access.revalidate ?? false);
|
|
247
|
+
if (!session) redirect(client.signInPath(returnTo));
|
|
248
|
+
return authorize(session, access);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
action<Args extends unknown[], Out>(
|
|
252
|
+
this: void,
|
|
253
|
+
policy: Permission | AccessPolicy<Permission>,
|
|
254
|
+
fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
|
|
255
|
+
options?: {
|
|
256
|
+
onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
|
|
257
|
+
},
|
|
258
|
+
): (...args: Args) => Promise<Out> {
|
|
259
|
+
const access: AccessPolicy<Permission> =
|
|
260
|
+
typeof policy === "string"
|
|
261
|
+
? { permission: policy, revalidate: true }
|
|
262
|
+
: { revalidate: true, ...policy };
|
|
263
|
+
return (async (...args: Args) => {
|
|
264
|
+
const resolved = await client.resolveSession(await cookieValue(), {
|
|
265
|
+
revalidate: access.revalidate ?? true,
|
|
266
|
+
});
|
|
267
|
+
let session: SSOSession<Permission>;
|
|
268
|
+
try {
|
|
269
|
+
session = authorize(resolved?.session ?? null, access);
|
|
270
|
+
} catch (error: unknown) {
|
|
271
|
+
// Solo las denegaciones de la política se convierten en valor, y
|
|
272
|
+
// solo si se pidió: un fallo del portal sigue lanzando.
|
|
273
|
+
if (options?.onDenied && isAuthorizationError(error)) {
|
|
274
|
+
return options.onDenied(error);
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
await persist(resolved?.cookie ?? null);
|
|
279
|
+
return fn(session, ...args);
|
|
280
|
+
}) as (...args: Args) => Promise<Out>;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return { ...self, components: bindComponents<Permission>(self) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { SSOForbiddenError } from "./errors";
|
|
288
|
+
export {
|
|
289
|
+
type AccessPolicy,
|
|
290
|
+
type AuthorizationError,
|
|
291
|
+
authorize,
|
|
292
|
+
isAuthorizationError,
|
|
293
|
+
SSOAuthRequiredError,
|
|
294
|
+
SSOMfaRequiredError,
|
|
295
|
+
} from "./policy";
|
|
296
|
+
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="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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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. */
|
|
8
|
+
export type SessionPayload = {
|
|
9
|
+
sub: string;
|
|
10
|
+
email: string;
|
|
11
|
+
emailVerified: boolean;
|
|
12
|
+
name: string | null;
|
|
13
|
+
image: string | null;
|
|
14
|
+
roles: string[];
|
|
15
|
+
permissions: string[];
|
|
16
|
+
amr: string[];
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken?: string;
|
|
19
|
+
/**
|
|
20
|
+
* El `id_token` de la sesión.
|
|
21
|
+
*
|
|
22
|
+
* Se guarda porque el cierre de sesión del portal lo exige: `end-session`
|
|
23
|
+
* necesita `id_token_hint` para saber qué sesión terminar, y sin él responde
|
|
24
|
+
* con un error y la sesión del portal sigue viva.
|
|
25
|
+
*/
|
|
26
|
+
idToken?: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deriva la clave de cifrado a partir del secreto configurado.
|
|
32
|
+
*
|
|
33
|
+
* AES-256-GCM necesita exactamente 32 bytes, y un secreto escrito por una
|
|
34
|
+
* persona casi nunca los mide. Se pasa por SHA-256 para obtenerlos sin
|
|
35
|
+
* obligar a nadie a generar una clave con el tamaño justo.
|
|
36
|
+
*/
|
|
37
|
+
function deriveKey(secret: string): Uint8Array {
|
|
38
|
+
if (secret.length < 32) {
|
|
39
|
+
throw new SSOConfigError(
|
|
40
|
+
"`secret` necesita al menos 32 caracteres. Genera uno con:\n" +
|
|
41
|
+
" node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return new Uint8Array(createHash("sha256").update(secret).digest());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cifra la sesión para guardarla en una cookie.
|
|
50
|
+
*
|
|
51
|
+
* Se cifra y no solo se firma porque el contenido incluye el access token. Una
|
|
52
|
+
* cookie firmada es legible por cualquiera que la tenga delante: un volcado de
|
|
53
|
+
* memoria, un log de proxy mal configurado o una copia del perfil del
|
|
54
|
+
* navegador bastarían para llevárselo.
|
|
55
|
+
*/
|
|
56
|
+
export async function sealSession(
|
|
57
|
+
payload: SessionPayload,
|
|
58
|
+
secret: string,
|
|
59
|
+
maxAgeSeconds: number,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
return new EncryptJWT({ ...payload })
|
|
62
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
|
63
|
+
.setIssuedAt()
|
|
64
|
+
.setExpirationTime(`${maxAgeSeconds}s`)
|
|
65
|
+
.encrypt(deriveKey(secret));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Descifra la cookie. Devuelve `null` si está corrupta, caducada o cifrada con
|
|
70
|
+
* otro secreto, en lugar de lanzar: para quien llama, todos esos casos
|
|
71
|
+
* significan lo mismo —no hay sesión utilizable— y tratarlos por separado solo
|
|
72
|
+
* añadiría ramas sin ninguna decisión distinta detrás.
|
|
73
|
+
*/
|
|
74
|
+
export async function openSession(
|
|
75
|
+
token: string | undefined,
|
|
76
|
+
secret: string,
|
|
77
|
+
): Promise<SessionPayload | null> {
|
|
78
|
+
if (!token) return null;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { payload } = await jwtDecrypt(token, deriveKey(secret));
|
|
82
|
+
return payload as unknown as SessionPayload;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|