@nekm/sveltekit-armor 0.2.2 → 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/contracts.ts CHANGED
@@ -26,7 +26,8 @@ export interface ArmorAccessToken extends JWTPayload {
26
26
  export interface ArmorTokens {
27
27
  readonly exchange: ArmorTokenExchange;
28
28
  readonly idToken: ArmorIdToken;
29
- readonly accessToken: ArmorAccessToken;
29
+ readonly accessToken: ArmorAccessToken | string;
30
+ readonly expiresAt: Date;
30
31
  }
31
32
 
32
33
  interface OauthBaseUrl {
@@ -36,6 +37,7 @@ interface OauthBaseUrl {
36
37
  readonly authorizeEndpoint?: never;
37
38
  readonly logoutEndpoint?: never;
38
39
  readonly tokenEndpoint?: never;
40
+ readonly refreshEndpoint?: never;
39
41
  }
40
42
 
41
43
  interface OauthEndpoints {
@@ -45,18 +47,21 @@ interface OauthEndpoints {
45
47
  readonly authorizeEndpoint: string;
46
48
  readonly logoutEndpoint?: string;
47
49
  readonly tokenEndpoint: string;
50
+ readonly refreshEndpoint: string;
48
51
  }
49
52
 
50
53
  type OauthEndpointsOrBaseUrl = OauthBaseUrl | OauthEndpoints;
51
54
 
52
55
  export interface ArmorConfig {
53
56
  readonly session: {
54
- readonly exists: (event: RequestEvent) => Promise<boolean> | boolean;
55
57
  readonly login: (
56
58
  event: RequestEvent,
57
59
  tokens: ArmorTokens,
58
60
  ) => Promise<void> | void;
59
61
  readonly logout: (event: RequestEvent) => Promise<void> | void;
62
+ readonly getTokens: (
63
+ event: RequestEvent,
64
+ ) => Promise<ArmorTokens | undefined> | ArmorTokens | undefined;
60
65
  };
61
66
  readonly oauth: OauthEndpointsOrBaseUrl & {
62
67
  readonly clientId: string;
package/src/errors.ts CHANGED
@@ -2,3 +2,4 @@ export class ArmorError extends Error {}
2
2
  export class ArmorOpenIdConfigError extends ArmorError {}
3
3
  export class ArmorInvalidStateError extends ArmorError {}
4
4
  export class ArmorAuthMissingError extends ArmorError {}
5
+ export class ArmorRefreshError extends ArmorError {}
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 routes = routeCreate(config);
13
+ const routeByPath = routeCreate(config);
14
+ const refresh = createRefresh(config);
16
15
 
17
16
  return async ({ event, resolve }) => {
18
- const routeHandle = routes.get(event.url.pathname);
17
+ const route = routeByPath.get(event.url.pathname);
19
18
 
20
- if (routeHandle) {
21
- return routeHandle({ event, resolve });
19
+ if (route && route.method === event.request.method) {
20
+ return route.handle({ event, resolve });
22
21
  }
23
22
 
24
- const exists = await config.session.exists(event);
23
+ const tokens = await config.session.getTokens(event);
25
24
 
26
- if (!exists) {
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
  }
@@ -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 = "/_armor/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);
@@ -19,6 +19,7 @@ export const routeLogoutFactory: RouteFactory = (config: ArmorConfig) => {
19
19
 
20
20
  return {
21
21
  path: ROUTE_PATH_LOGOUT,
22
+ method: "GET",
22
23
  async handle({ event }) {
23
24
  const state = randomUUID();
24
25
  cookieSet(event.cookies, COOKIE_STATE, state);
@@ -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
 
@@ -113,7 +114,10 @@ export const routeRedirectLoginFactory: RouteFactory = (
113
114
  await config.session.login(event, {
114
115
  exchange,
115
116
  idToken: idToken as ArmorIdToken,
116
- accessToken,
117
+ // Generally, IdP's require an audience to get a JWT
118
+ // access token. Most cases, this doesn't matter.
119
+ accessToken: accessToken ?? exchange.access_token,
120
+ expiresAt: createExpiresAt(exchange.expires_in),
117
121
  });
118
122
 
119
123
  throw redirect(302, "/");
@@ -15,6 +15,7 @@ export const routeRedirectLogoutFactory: RouteFactory = (
15
15
 
16
16
  return {
17
17
  path: ROUTE_PATH_REDIRECT_LOGOUT,
18
+ method: "GET",
18
19
  async handle({ event }) {
19
20
  eventStateValidOrThrow(event);
20
21
 
@@ -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
+ };
@@ -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, Handle> {
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.handle]),
32
+ .map((route) => [route.path, route]),
29
33
  );
30
34
  }
@@ -8,8 +8,10 @@ import {
8
8
  import { ArmorConfig, ArmorTokens } from "../contracts";
9
9
  import { ArmorAuthMissingError } from "../errors";
10
10
 
11
- function cookieSessionExists({ cookies }: RequestEvent): boolean {
12
- return Boolean(cookies.get(COOKIE_TOKENS));
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
- exists: cookieSessionExists,
39
+ getTokens: cookieSessionGetTokens,
38
40
  login: cookieSessionLogin,
39
41
  logout: cookieSessionLogout,
40
42
  };
package/src/utils/jwt.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { ArmorConfig } from "../contracts";
2
2
  import { JWTPayload, jwtVerify, JWTVerifyGetKey, JWTVerifyOptions } from "jose";
3
+ import { throwIfUndefined } from "@nekm/core";
4
+
5
+ function jwtIsCompactJwt(token: string): boolean {
6
+ // Must be three base64url segments
7
+ const parts = token.trim().split(".");
8
+ return parts.length === 3 && parts.every((p) => p.length > 0);
9
+ }
3
10
 
4
11
  export function jwtVerifyIdToken(
5
12
  config: ArmorConfig,
6
13
  jwks: JWTVerifyGetKey,
7
14
  idToken: string,
8
15
  ): Promise<JWTPayload> {
9
- return jwtVerifyToken(
16
+ const payload = jwtVerifyToken(
10
17
  jwks,
11
18
  {
12
19
  issuer: config.oauth.issuer,
@@ -14,13 +21,16 @@ export function jwtVerifyIdToken(
14
21
  },
15
22
  idToken,
16
23
  );
24
+ throwIfUndefined(payload);
25
+ // @ts-expect-error We're already verifying non-null above.
26
+ return payload;
17
27
  }
18
28
 
19
29
  export function jwtVerifyAccessToken(
20
30
  config: ArmorConfig,
21
31
  jwks: JWTVerifyGetKey,
22
32
  accessToken: string,
23
- ): Promise<JWTPayload> {
33
+ ): Promise<JWTPayload | undefined> {
24
34
  const opts: JWTVerifyOptions = { issuer: config.oauth.issuer };
25
35
 
26
36
  if (config.oauth.audience) {
@@ -30,11 +40,33 @@ export function jwtVerifyAccessToken(
30
40
  return jwtVerifyToken(jwks, opts, accessToken);
31
41
  }
32
42
 
43
+ function isInvalidCompactJwt(error: unknown): boolean {
44
+ return Boolean(
45
+ typeof error === "object" &&
46
+ error &&
47
+ "message" in error &&
48
+ typeof error.message === "string" &&
49
+ /invalid compact jws/gi.test(error.message),
50
+ );
51
+ }
52
+
33
53
  async function jwtVerifyToken(
34
54
  jwks: JWTVerifyGetKey,
35
55
  opts: JWTVerifyOptions,
36
56
  token: string,
37
- ): Promise<JWTPayload> {
38
- const { payload } = await jwtVerify(token, jwks, opts);
39
- return payload;
57
+ ): Promise<JWTPayload | undefined> {
58
+ try {
59
+ if (!jwtIsCompactJwt(token)) {
60
+ return undefined;
61
+ }
62
+
63
+ const { payload } = await jwtVerify(token, jwks, opts);
64
+ return payload;
65
+ } catch (error) {
66
+ if (isInvalidCompactJwt(error)) {
67
+ return undefined;
68
+ }
69
+
70
+ throw error;
71
+ }
40
72
  }
@@ -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
+ }
@@ -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
+ }