@nekm/sveltekit-armor 0.2.4 → 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/dist/browser/index.d.ts +8 -0
- package/dist/contracts.d.ts +4 -1
- package/dist/errors.d.ts +2 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.esm.js +135 -18
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +133 -18
- package/dist/index.js.map +1 -1
- package/dist/routes/refresh.d.ts +3 -0
- package/dist/routes/routes.d.ts +2 -1
- package/dist/utils/refresh.d.ts +3 -0
- package/dist/utils/utils.d.ts +3 -1
- package/package.json +23 -12
- package/src/browser/index.ts +32 -0
- package/src/contracts.ts +6 -1
- package/src/errors.ts +1 -0
- package/src/index.ts +25 -11
- package/src/routes/login.ts +3 -1
- package/src/routes/logout.ts +1 -0
- package/src/routes/redirect-login.ts +3 -1
- package/src/routes/redirect-logout.ts +1 -0
- package/src/routes/refresh.ts +39 -0
- package/src/routes/routes.ts +6 -2
- package/src/session/cookie.ts +5 -3
- package/src/utils/refresh.ts +87 -0
- package/src/utils/utils.ts +13 -1
package/src/index.ts
CHANGED
|
@@ -1,32 +1,45 @@
|
|
|
1
1
|
import { redirect, type Handle } from "@sveltejs/kit";
|
|
2
2
|
import { ROUTE_PATH_LOGIN } from "./routes/login";
|
|
3
3
|
import type { ArmorConfig, ArmorOpenIdConfig, ArmorTokens } from "./contracts";
|
|
4
|
-
import { ROUTE_PATH_LOGOUT } from "./routes/logout";
|
|
5
4
|
import { routeCreate } from "./routes/routes";
|
|
6
|
-
import { ArmorOpenIdConfigError } from "./errors";
|
|
5
|
+
import { ArmorOpenIdConfigError, ArmorRefreshError } from "./errors";
|
|
6
|
+
import { shouldRefresh } from "./utils/utils";
|
|
7
|
+
import { createRefresh } from "./utils/refresh";
|
|
7
8
|
|
|
8
9
|
export type { ArmorConfig, ArmorTokens };
|
|
9
10
|
export { armorCookieSession, armorCookieSessionGet } from "./session/cookie";
|
|
10
11
|
|
|
11
|
-
export const ARMOR_LOGIN = ROUTE_PATH_LOGIN;
|
|
12
|
-
export const ARMOR_LOGOUT = ROUTE_PATH_LOGOUT;
|
|
13
|
-
|
|
14
12
|
export function armor(config: ArmorConfig): Handle {
|
|
15
|
-
const
|
|
13
|
+
const routeByPath = routeCreate(config);
|
|
14
|
+
const refresh = createRefresh(config);
|
|
16
15
|
|
|
17
16
|
return async ({ event, resolve }) => {
|
|
18
|
-
const
|
|
17
|
+
const route = routeByPath.get(event.url.pathname);
|
|
19
18
|
|
|
20
|
-
if (
|
|
21
|
-
return
|
|
19
|
+
if (route && route.method === event.request.method) {
|
|
20
|
+
return route.handle({ event, resolve });
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
const
|
|
23
|
+
const tokens = await config.session.getTokens(event);
|
|
25
24
|
|
|
26
|
-
if (!
|
|
25
|
+
if (!tokens) {
|
|
27
26
|
throw redirect(302, ROUTE_PATH_LOGIN);
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
try {
|
|
30
|
+
if (shouldRefresh(tokens)) {
|
|
31
|
+
console.log("Refreshing token...");
|
|
32
|
+
await refresh(event, tokens);
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof ArmorRefreshError) {
|
|
36
|
+
console.error("Could not refresh token. Redirect user to login...");
|
|
37
|
+
throw redirect(302, ROUTE_PATH_LOGIN);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
return resolve(event);
|
|
31
44
|
};
|
|
32
45
|
}
|
|
@@ -65,6 +78,7 @@ export async function armorConfigFromOpenId(
|
|
|
65
78
|
issuer: body.issuer,
|
|
66
79
|
jwksEndpoint: body.jwks_uri,
|
|
67
80
|
logoutEndpoint: body.end_session_endpoint ?? undefined,
|
|
81
|
+
refreshEndpoint: body.token_endpoint,
|
|
68
82
|
},
|
|
69
83
|
};
|
|
70
84
|
}
|
package/src/routes/login.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import type { RouteFactory } from "./routes";
|
|
7
7
|
import { COOKIE_STATE, cookieSet } from "../utils/cookie";
|
|
8
8
|
import { urlConcat } from "../utils/utils";
|
|
9
|
+
import { ARMOR_LOGIN } from "../browser";
|
|
9
10
|
|
|
10
|
-
export const ROUTE_PATH_LOGIN =
|
|
11
|
+
export const ROUTE_PATH_LOGIN = ARMOR_LOGIN;
|
|
11
12
|
|
|
12
13
|
export const routeLoginFactory: RouteFactory = (config: ArmorConfig) => {
|
|
13
14
|
const authorizeEndpoint =
|
|
@@ -18,6 +19,7 @@ export const routeLoginFactory: RouteFactory = (config: ArmorConfig) => {
|
|
|
18
19
|
|
|
19
20
|
return {
|
|
20
21
|
path: ROUTE_PATH_LOGIN,
|
|
22
|
+
method: "GET",
|
|
21
23
|
async handle({ event }) {
|
|
22
24
|
const state = randomUUID();
|
|
23
25
|
cookieSet(event.cookies, COOKIE_STATE, state);
|
package/src/routes/logout.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
import { queryParamsCreate, throwIfUndefined } from "@nekm/core";
|
|
8
8
|
import { createRemoteJWKSet } from "jose";
|
|
9
9
|
import type { RouteFactory } from "./routes";
|
|
10
|
-
import { urlConcat, isTokenExchange } from "../utils/utils";
|
|
10
|
+
import { urlConcat, isTokenExchange, createExpiresAt } from "../utils/utils";
|
|
11
11
|
import { jwtVerifyAccessToken, jwtVerifyIdToken } from "../utils/jwt";
|
|
12
12
|
import { eventStateValidOrThrow } from "../utils/event";
|
|
13
13
|
|
|
@@ -70,6 +70,7 @@ export const routeRedirectLoginFactory: RouteFactory = (
|
|
|
70
70
|
|
|
71
71
|
return {
|
|
72
72
|
path: ROUTE_PATH_REDIRECT_LOGIN,
|
|
73
|
+
method: "GET",
|
|
73
74
|
async handle({ event }) {
|
|
74
75
|
eventStateValidOrThrow(event);
|
|
75
76
|
|
|
@@ -116,6 +117,7 @@ export const routeRedirectLoginFactory: RouteFactory = (
|
|
|
116
117
|
// Generally, IdP's require an audience to get a JWT
|
|
117
118
|
// access token. Most cases, this doesn't matter.
|
|
118
119
|
accessToken: accessToken ?? exchange.access_token,
|
|
120
|
+
expiresAt: createExpiresAt(exchange.expires_in),
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
throw redirect(302, "/");
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { error, json } from "@sveltejs/kit";
|
|
2
|
+
import type { ArmorConfig } from "../contracts";
|
|
3
|
+
import type { RouteFactory } from "./routes";
|
|
4
|
+
import { createRefresh } from "../utils/refresh";
|
|
5
|
+
import { ARMOR_REFRESH } from "../browser";
|
|
6
|
+
import { ArmorRefreshError } from "../errors";
|
|
7
|
+
|
|
8
|
+
export const ROUTE_PATH_REFRESH = ARMOR_REFRESH;
|
|
9
|
+
|
|
10
|
+
export const routeRefreshFactory: RouteFactory = (config: ArmorConfig) => {
|
|
11
|
+
const refresh = createRefresh(config);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
path: ROUTE_PATH_REFRESH,
|
|
15
|
+
method: "POST",
|
|
16
|
+
async handle({ event }) {
|
|
17
|
+
try {
|
|
18
|
+
const tokens = await config.session.getTokens(event);
|
|
19
|
+
|
|
20
|
+
if (!tokens) {
|
|
21
|
+
return error(401, "Unauthorized");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { idToken, expiresAt, accessToken } = await refresh(
|
|
25
|
+
event,
|
|
26
|
+
tokens,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return json({ idToken, expiresAt, accessToken });
|
|
30
|
+
} catch (ex) {
|
|
31
|
+
if (ex instanceof ArmorRefreshError) {
|
|
32
|
+
return error(401, "Unauthorized");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw ex;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
};
|
package/src/routes/routes.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { routeLoginFactory } from "./login";
|
|
|
4
4
|
import { routeLogoutFactory } from "./logout";
|
|
5
5
|
import { routeRedirectLogoutFactory } from "./redirect-logout";
|
|
6
6
|
import { routeRedirectLoginFactory } from "./redirect-login";
|
|
7
|
+
import { routeRefreshFactory } from "./refresh";
|
|
7
8
|
|
|
8
9
|
export interface Route {
|
|
9
10
|
readonly path: string;
|
|
10
11
|
readonly handle: Handle;
|
|
12
|
+
readonly method: "GET" | "POST";
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export type RouteFactory = (config: ArmorConfig) => Route | undefined;
|
|
@@ -17,14 +19,16 @@ const routeFactories = Object.freeze([
|
|
|
17
19
|
routeLogoutFactory,
|
|
18
20
|
routeRedirectLoginFactory,
|
|
19
21
|
routeRedirectLogoutFactory,
|
|
22
|
+
routeRefreshFactory,
|
|
20
23
|
]);
|
|
21
24
|
|
|
22
|
-
export function routeCreate(config: ArmorConfig): Map<string,
|
|
25
|
+
export function routeCreate(config: ArmorConfig): Map<string, Route> {
|
|
26
|
+
// @ts-expect-error Incorrect typing error.
|
|
23
27
|
return new Map(
|
|
24
28
|
routeFactories
|
|
25
29
|
.map((routeFactory) => routeFactory(config))
|
|
26
30
|
.filter((route) => Boolean(route))
|
|
27
31
|
// @ts-expect-error Incorrect typing error.
|
|
28
|
-
.map((route) => [route.path, route
|
|
32
|
+
.map((route) => [route.path, route]),
|
|
29
33
|
);
|
|
30
34
|
}
|
package/src/session/cookie.ts
CHANGED
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
import { ArmorConfig, ArmorTokens } from "../contracts";
|
|
9
9
|
import { ArmorAuthMissingError } from "../errors";
|
|
10
10
|
|
|
11
|
-
function
|
|
12
|
-
|
|
11
|
+
function cookieSessionGetTokens({
|
|
12
|
+
cookies,
|
|
13
|
+
}: RequestEvent): ArmorTokens | undefined {
|
|
14
|
+
return cookies.get(COOKIE_TOKENS) as ArmorTokens | undefined;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function cookieSessionLogin(
|
|
@@ -34,7 +36,7 @@ export function armorCookieSessionGet({ cookies }: RequestEvent): ArmorTokens {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export const armorCookieSession: ArmorConfig["session"] = {
|
|
37
|
-
|
|
39
|
+
getTokens: cookieSessionGetTokens,
|
|
38
40
|
login: cookieSessionLogin,
|
|
39
41
|
logout: cookieSessionLogout,
|
|
40
42
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createRemoteJWKSet } from "jose";
|
|
2
|
+
import {
|
|
3
|
+
ArmorConfig,
|
|
4
|
+
ArmorIdToken,
|
|
5
|
+
ArmorTokenExchange,
|
|
6
|
+
ArmorTokens,
|
|
7
|
+
} from "../contracts";
|
|
8
|
+
import { ArmorRefreshError } from "../errors";
|
|
9
|
+
import { createExpiresAt, urlConcat } from "./utils";
|
|
10
|
+
import { jwtVerifyAccessToken, jwtVerifyIdToken } from "./jwt";
|
|
11
|
+
import { RequestEvent } from "@sveltejs/kit";
|
|
12
|
+
|
|
13
|
+
export function createRefresh(config: ArmorConfig) {
|
|
14
|
+
const refreshEndpoint =
|
|
15
|
+
config.oauth.refreshEndpoint ??
|
|
16
|
+
urlConcat(config.oauth.baseUrl, "oauth2/token");
|
|
17
|
+
|
|
18
|
+
const jwksUrl = new URL(
|
|
19
|
+
config.oauth.jwksEndpoint ??
|
|
20
|
+
urlConcat(config.oauth.baseUrl, ".well-known/jwks.json"),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const refresh = async (
|
|
24
|
+
fetch: typeof global.fetch,
|
|
25
|
+
refreshToken: string,
|
|
26
|
+
): Promise<ArmorTokenExchange> => {
|
|
27
|
+
const body = new URLSearchParams({
|
|
28
|
+
grant_type: "refresh_token",
|
|
29
|
+
client_id: config.oauth.clientId,
|
|
30
|
+
client_secret: config.oauth.clientSecret,
|
|
31
|
+
refresh_token: refreshToken,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (config.oauth.scope) {
|
|
35
|
+
body.set("scope", config.oauth.scope);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const response = await fetch(refreshEndpoint, {
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
41
|
+
Accept: "application/json",
|
|
42
|
+
},
|
|
43
|
+
body: body.toString(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const error = await response.text();
|
|
48
|
+
throw new ArmorRefreshError(`Could not refresh token: ${error}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const json: ArmorTokenExchange = await response.json();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...json,
|
|
55
|
+
refresh_token: json.refresh_token ?? refreshToken,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return async (
|
|
60
|
+
event: RequestEvent,
|
|
61
|
+
tokens: ArmorTokens,
|
|
62
|
+
): Promise<ArmorTokens> => {
|
|
63
|
+
const refreshToken = tokens.exchange?.refresh_token;
|
|
64
|
+
|
|
65
|
+
if (!refreshToken) {
|
|
66
|
+
throw new ArmorRefreshError("Could not find refresh token");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const newExchange = await refresh(event.fetch, refreshToken);
|
|
70
|
+
|
|
71
|
+
const jwks = createRemoteJWKSet(jwksUrl);
|
|
72
|
+
|
|
73
|
+
const [idToken, accessToken] = await Promise.all([
|
|
74
|
+
jwtVerifyIdToken(config, jwks, newExchange.id_token),
|
|
75
|
+
jwtVerifyAccessToken(config, jwks, newExchange.access_token),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
exchange: newExchange,
|
|
80
|
+
idToken: idToken as ArmorIdToken,
|
|
81
|
+
// Generally, IdP's require an audience to get a JWT
|
|
82
|
+
// access token. Most cases, this doesn't matter.
|
|
83
|
+
accessToken: accessToken ?? newExchange.access_token,
|
|
84
|
+
expiresAt: createExpiresAt(newExchange.expires_in),
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/utils/utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { strTrimEnd, strTrimStart } from "@nekm/core";
|
|
2
|
-
import type { ArmorTokenExchange } from "../contracts";
|
|
2
|
+
import type { ArmorTokenExchange, ArmorTokens } from "../contracts";
|
|
3
3
|
|
|
4
4
|
export function urlConcat(origin: string, path: string): string {
|
|
5
5
|
return [strTrimEnd(origin, "/"), strTrimStart(path, "/")].join("/");
|
|
@@ -21,3 +21,15 @@ export function isTokenExchange(value: unknown): value is ArmorTokenExchange {
|
|
|
21
21
|
(typeof obj.scope === "string" || obj.scope === undefined)
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
const MINUTES_MS = 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export function shouldRefresh(tokens: ArmorTokens) {
|
|
28
|
+
return tokens.expiresAt.getTime() < Date.now() + 5 * MINUTES_MS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createExpiresAt(seconds: number): Date {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
now.setSeconds(now.getSeconds() + seconds);
|
|
34
|
+
return now;
|
|
35
|
+
}
|