@thecodeorigin/auth 0.0.10 → 0.0.12

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.
Files changed (46) hide show
  1. package/README.md +137 -72
  2. package/dist/contract/index.d.mts +80 -3
  3. package/dist/contract/index.d.ts +80 -3
  4. package/dist/contract/index.mjs +34 -2
  5. package/dist/module.d.mts +4 -0
  6. package/dist/module.d.ts +4 -0
  7. package/dist/module.json +1 -1
  8. package/dist/module.mjs +33 -12
  9. package/dist/runtime/app/composables/useAuth.d.ts +5 -0
  10. package/dist/runtime/app/composables/useAuth.js +50 -10
  11. package/dist/runtime/app/composables/useAuthLogin.d.ts +11 -0
  12. package/dist/runtime/app/composables/useAuthLogin.js +73 -0
  13. package/dist/runtime/app/middleware/auth.global.js +2 -0
  14. package/dist/runtime/app/plugins/session-sync.client.d.ts +2 -0
  15. package/dist/runtime/app/plugins/session-sync.client.js +47 -0
  16. package/dist/runtime/app/utils/return-path.d.ts +1 -0
  17. package/dist/runtime/app/utils/return-path.js +18 -0
  18. package/dist/runtime/app/utils/session-recovery.d.ts +5 -0
  19. package/dist/runtime/app/utils/session-recovery.js +23 -0
  20. package/dist/runtime/server/api/_auth/impersonate.post.d.ts +1 -0
  21. package/dist/runtime/server/api/_auth/impersonate.post.js +47 -27
  22. package/dist/runtime/server/api/_auth/organizations/switch.post.d.ts +1 -0
  23. package/dist/runtime/server/api/_auth/organizations/switch.post.js +12 -17
  24. package/dist/runtime/server/api/_auth/session-restore.get.d.ts +5 -0
  25. package/dist/runtime/server/api/_auth/session-restore.get.js +6 -0
  26. package/dist/runtime/server/api/_auth/session.get.d.ts +1 -0
  27. package/dist/runtime/server/api/_auth/sign-out.post.js +3 -0
  28. package/dist/runtime/server/api/_auth/stop-impersonating.post.d.ts +1 -0
  29. package/dist/runtime/server/api/_auth/stop-impersonating.post.js +18 -16
  30. package/dist/runtime/server/index.d.ts +2 -2
  31. package/dist/runtime/server/index.js +1 -1
  32. package/dist/runtime/server/routes/callback.get.js +62 -32
  33. package/dist/runtime/server/routes/sign-in.get.js +8 -6
  34. package/dist/runtime/server/routes/sign-out.get.d.ts +1 -0
  35. package/dist/runtime/server/routes/sign-out.get.js +4 -8
  36. package/dist/runtime/server/utils/idp.d.ts +2 -7
  37. package/dist/runtime/server/utils/idp.js +16 -64
  38. package/dist/runtime/server/utils/integration-key.d.ts +18 -0
  39. package/dist/runtime/server/utils/local-auth.d.ts +351 -0
  40. package/dist/runtime/server/utils/local-auth.js +43 -0
  41. package/dist/runtime/server/utils/oidc.d.ts +23 -2
  42. package/dist/runtime/server/utils/oidc.js +82 -5
  43. package/dist/runtime/server/utils/session.d.ts +30 -9
  44. package/dist/runtime/server/utils/session.js +189 -61
  45. package/dist/runtime/types.d.ts +12 -0
  46. package/package.json +9 -33
@@ -35,6 +35,7 @@ export declare function useAuth(): {
35
35
  name: string | null;
36
36
  picture: string | null;
37
37
  } | null;
38
+ expiresAt?: number | undefined;
38
39
  } | null, {
39
40
  user: {
40
41
  sub: string;
@@ -69,6 +70,7 @@ export declare function useAuth(): {
69
70
  name: string | null;
70
71
  picture: string | null;
71
72
  } | null;
73
+ expiresAt?: number | undefined;
72
74
  } | null>;
73
75
  user: import("vue").ComputedRef<{
74
76
  sub: string;
@@ -118,5 +120,8 @@ export declare function useAuth(): {
118
120
  prompt?: SignInPrompt;
119
121
  }) => string | false | void | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | Promise<false | void | import("vue-router").NavigationFailure>;
120
122
  signOut: () => Promise<void>;
123
+ logout: () => Promise<string | false | void | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | import("vue-router").NavigationFailure>;
124
+ switchAccount: (redirect?: string) => string | false | void | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | Promise<false | void | import("vue-router").NavigationFailure>;
121
125
  refresh: () => Promise<void>;
126
+ recoverSession: () => Promise<boolean>;
122
127
  };
@@ -1,15 +1,40 @@
1
1
  import { computed } from "vue";
2
- import { navigateTo, useRuntimeConfig, useState } from "#app";
2
+ import { clearNuxtData, navigateTo, useNuxtApp, useRequestFetch, useRuntimeConfig, useState } from "#app";
3
+ import { safeReturnPath } from "../utils/return-path.js";
4
+ import { recoverLocalSession } from "../utils/session-recovery.js";
5
+ const pendingRecovery = /* @__PURE__ */ new WeakMap();
3
6
  export function useAuth() {
4
7
  const session = useState("tco-auth-session", () => null);
5
- const routes = useRuntimeConfig().public.auth?.routes ?? {};
8
+ const config = useRuntimeConfig().public.auth;
9
+ const routes = { login: "/auth/login", signIn: "/auth/sign-in", home: "/", ...config?.routes };
10
+ const requestFetch = useRequestFetch();
11
+ const nuxtApp = useNuxtApp();
12
+ function broadcastSession() {
13
+ if (!import.meta.client)
14
+ return;
15
+ try {
16
+ localStorage.setItem(`${config.logoutIntentCookieName}:changed`, crypto.randomUUID());
17
+ } catch {
18
+ }
19
+ }
6
20
  const user = computed(() => session.value?.user ?? null);
7
21
  const loggedIn = computed(() => !!session.value);
8
22
  const abilities = computed(() => session.value?.abilities ?? []);
9
23
  const impersonator = computed(() => session.value?.impersonator ?? null);
10
24
  const isImpersonating = computed(() => !!session.value?.impersonator);
11
25
  async function refresh() {
12
- session.value = await $fetch("/api/_auth/session");
26
+ const previous = session.value;
27
+ const checked = await requestFetch("/api/_auth/session");
28
+ if (session.value === previous)
29
+ session.value = checked;
30
+ }
31
+ async function recoverSession() {
32
+ const pending = pendingRecovery.get(nuxtApp);
33
+ if (pending)
34
+ return pending;
35
+ const recovery = recoverLocalSession(session, () => requestFetch("/api/_auth/session")).finally(() => pendingRecovery.delete(nuxtApp));
36
+ pendingRecovery.set(nuxtApp, recovery);
37
+ return recovery;
13
38
  }
14
39
  function getOrganizations() {
15
40
  return session.value?.organizations ?? [];
@@ -19,6 +44,8 @@ export function useAuth() {
19
44
  method: "POST",
20
45
  body: { organizationId: orgId }
21
46
  });
47
+ clearNuxtData();
48
+ broadcastSession();
22
49
  }
23
50
  function getOrganizationUsers(organizationId, query) {
24
51
  return $fetch(
@@ -34,24 +61,34 @@ export function useAuth() {
34
61
  method: "POST",
35
62
  body: { userId }
36
63
  });
64
+ clearNuxtData();
65
+ broadcastSession();
37
66
  }
38
67
  async function stopImpersonating() {
39
68
  session.value = await $fetch("/api/_auth/stop-impersonating", { method: "POST" });
69
+ clearNuxtData();
70
+ broadcastSession();
40
71
  }
41
72
  function signIn(redirect, options) {
42
73
  const params = new URLSearchParams();
43
- if (redirect)
44
- params.set("redirect", redirect);
45
- if (options?.prompt)
46
- params.set("prompt", options.prompt);
74
+ params.set("redirect", safeReturnPath(redirect, routes.home));
75
+ params.set("prompt", options?.prompt ?? "select_account");
47
76
  const qs = params.toString();
48
77
  return navigateTo(qs ? `${routes.signIn}?${qs}` : routes.signIn, { external: true });
49
78
  }
50
79
  async function signOut() {
51
80
  const signOutRoute = "/api/_auth/sign-out";
52
- await $fetch(signOutRoute, { method: "POST" }).catch(() => {
53
- });
81
+ await $fetch(signOutRoute, { method: "POST" });
54
82
  session.value = null;
83
+ clearNuxtData();
84
+ broadcastSession();
85
+ }
86
+ async function logout() {
87
+ await signOut();
88
+ return navigateTo(`${routes.login}?loggedout=1`, { external: true });
89
+ }
90
+ function switchAccount(redirect) {
91
+ return signIn(redirect, { prompt: "select_account" });
55
92
  }
56
93
  return {
57
94
  session,
@@ -68,6 +105,9 @@ export function useAuth() {
68
105
  stopImpersonating,
69
106
  signIn,
70
107
  signOut,
71
- refresh
108
+ logout,
109
+ switchAccount,
110
+ refresh,
111
+ recoverSession
72
112
  };
73
113
  }
@@ -0,0 +1,11 @@
1
+ /** Shared behavior; each application keeps its own presentation and optional demo UI. */
2
+ export declare function useAuthLogin(): {
3
+ error: import("vue").ComputedRef<string | null>;
4
+ loggedOut: import("vue").ComputedRef<boolean>;
5
+ loading: import("vue").Ref<boolean, boolean>;
6
+ redirectTo: import("vue").ComputedRef<string>;
7
+ restoreAvailable: import("vue").ComputedRef<boolean>;
8
+ handleRestoreSession: () => Promise<void>;
9
+ handleSignIn: () => Promise<void>;
10
+ handleUseAnotherAccount: () => Promise<void>;
11
+ };
@@ -0,0 +1,73 @@
1
+ import { computed, ref } from "vue";
2
+ import { navigateTo, useAsyncData, useCookie, useRequestFetch, useRoute, useRuntimeConfig } from "#app";
3
+ import { safeReturnPath } from "../utils/return-path.js";
4
+ import { useAuth } from "./useAuth.js";
5
+ const ERROR_MESSAGES = {
6
+ access_denied: "Sign-in was canceled or this account cannot access this application.",
7
+ invalid_state: "This sign-in attempt expired or was replaced. Please try again.",
8
+ invalid_id_token: "Your identity could not be verified. Please sign in again.",
9
+ id_token_invalid: "Your identity could not be verified. Please sign in again.",
10
+ id_token_missing: "Your identity could not be verified. Please sign in again.",
11
+ token_exchange_failed: "We could not complete sign-in. Please try again.",
12
+ userinfo_invalid: "Your account information could not be verified. Please try again.",
13
+ email_unverified: "Verify your email address in THECODEORIGIN ID, then try again."
14
+ };
15
+ export function useAuthLogin() {
16
+ const route = useRoute();
17
+ const { signIn, switchAccount, stopImpersonating } = useAuth();
18
+ const config = useRuntimeConfig().public.auth;
19
+ const logoutIntent = useCookie(config.logoutIntentCookieName);
20
+ const loading = ref(false);
21
+ const localError = ref(null);
22
+ const requestFetch = useRequestFetch();
23
+ const { data: restore } = useAsyncData("tco-auth-session-restore", async () => {
24
+ try {
25
+ return await requestFetch("/api/_auth/session-restore");
26
+ } catch {
27
+ return { available: false };
28
+ }
29
+ }, { default: () => ({ available: false }) });
30
+ const restoreAvailable = computed(() => restore.value.available);
31
+ const error = computed(() => localError.value ?? (typeof route.query.error === "string" ? ERROR_MESSAGES[route.query.error] ?? "Sign-in could not be completed. Please try again." : null));
32
+ const loggedOut = computed(() => route.query.loggedout != null || logoutIntent.value === "1");
33
+ const redirectTo = computed(() => {
34
+ const target = safeReturnPath(route.query.redirect, config.routes.home);
35
+ const pathname = target.split(/[?#]/u)[0];
36
+ return [config.routes.login, config.routes.signIn, config.routes.callback, config.routes.signOut].includes(pathname) ? config.routes.home : target;
37
+ });
38
+ async function start(anotherAccount) {
39
+ if (loading.value)
40
+ return;
41
+ loading.value = true;
42
+ localError.value = null;
43
+ try {
44
+ await (anotherAccount ? switchAccount(redirectTo.value) : signIn(redirectTo.value));
45
+ } catch {
46
+ localError.value = "Unable to open sign-in. Please try again.";
47
+ loading.value = false;
48
+ }
49
+ }
50
+ async function handleRestoreSession() {
51
+ if (loading.value)
52
+ return;
53
+ loading.value = true;
54
+ localError.value = null;
55
+ try {
56
+ await stopImpersonating();
57
+ await navigateTo(redirectTo.value, { external: true });
58
+ } catch {
59
+ localError.value = "Unable to restore your session. Please sign in again.";
60
+ loading.value = false;
61
+ }
62
+ }
63
+ return {
64
+ error,
65
+ loggedOut,
66
+ loading,
67
+ redirectTo,
68
+ restoreAvailable,
69
+ handleRestoreSession,
70
+ handleSignIn: () => start(false),
71
+ handleUseAnotherAccount: () => start(true)
72
+ };
73
+ }
@@ -6,6 +6,8 @@ export default defineNuxtRouteMiddleware((to) => {
6
6
  const routes = authConfig?.routes;
7
7
  const logoutIntentCookieName = authConfig?.logoutIntentCookieName ?? "tco_auth_logout";
8
8
  const logoutIntent = useCookie(logoutIntentCookieName);
9
+ if (session.value?.expiresAt && session.value.expiresAt <= Date.now())
10
+ session.value = null;
9
11
  const authed = !!session.value;
10
12
  const isPublic = to.meta.public === true || to.meta.unauthenticatedOnly === true;
11
13
  if (authed && to.meta.unauthenticatedOnly)
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,47 @@
1
+ import { watch } from "vue";
2
+ import { defineNuxtPlugin, useRuntimeConfig, useState } from "#app";
3
+ function identity(session) {
4
+ return JSON.stringify([session?.user.sub, session?.activeOrg, session?.impersonator?.sub, session?.expiresAt]);
5
+ }
6
+ export default defineNuxtPlugin((nuxtApp) => {
7
+ const session = useState("tco-auth-session", () => null);
8
+ const config = useRuntimeConfig().public.auth;
9
+ const key = `${config.logoutIntentCookieName}:changed`;
10
+ let checking = false;
11
+ let expiryTimer;
12
+ watch(() => session.value?.expiresAt, (expiresAt) => {
13
+ clearTimeout(expiryTimer);
14
+ if (expiresAt)
15
+ expiryTimer = setTimeout(() => window.location.reload(), Math.max(0, expiresAt - Date.now()));
16
+ }, { immediate: true });
17
+ async function revalidate() {
18
+ if (checking || document.visibilityState === "hidden")
19
+ return;
20
+ checking = true;
21
+ const previous = session.value;
22
+ try {
23
+ const checked = await $fetch("/api/_auth/session");
24
+ if (session.value === previous && identity(checked) !== identity(previous))
25
+ window.location.reload();
26
+ } catch {
27
+ } finally {
28
+ checking = false;
29
+ }
30
+ }
31
+ window.addEventListener("focus", revalidate);
32
+ document.addEventListener("visibilitychange", revalidate);
33
+ window.addEventListener("storage", (event) => {
34
+ if (event.key === key)
35
+ void revalidate();
36
+ });
37
+ nuxtApp.hook("app:mounted", () => {
38
+ try {
39
+ const current = identity(session.value);
40
+ if (localStorage.getItem(`${key}:identity`) !== current) {
41
+ localStorage.setItem(`${key}:identity`, current);
42
+ localStorage.setItem(key, crypto.randomUUID());
43
+ }
44
+ } catch {
45
+ }
46
+ });
47
+ });
@@ -0,0 +1 @@
1
+ export declare function safeReturnPath(target: unknown, fallback?: string): string;
@@ -0,0 +1,18 @@
1
+ function containsUnsafeCharacters(value) {
2
+ return [...value].some((character) => character === "\\" || character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
3
+ }
4
+ export function safeReturnPath(target, fallback = "/") {
5
+ if (typeof target !== "string" || !target.startsWith("/") || target.startsWith("//") || containsUnsafeCharacters(target))
6
+ return fallback;
7
+ try {
8
+ const decoded = decodeURIComponent(target);
9
+ if (decoded.startsWith("//") || containsUnsafeCharacters(decoded))
10
+ return fallback;
11
+ const url = new URL(target, "https://application.invalid");
12
+ if (url.origin !== "https://application.invalid")
13
+ return fallback;
14
+ return `${url.pathname}${url.search}${url.hash}`;
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
@@ -0,0 +1,5 @@
1
+ import type { PublicSession } from '../../../contract/index.js';
2
+ /** A failed request cannot prove that authentication has ended. */
3
+ export declare function recoverLocalSession(state: {
4
+ value: PublicSession | null;
5
+ }, fetchSession: () => Promise<PublicSession | null>, wait?: (ms: number) => Promise<void>): Promise<boolean>;
@@ -0,0 +1,23 @@
1
+ export async function recoverLocalSession(state, fetchSession, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
2
+ const previous = state.value;
3
+ for (const delay of [0, 250, 750]) {
4
+ if (delay)
5
+ await wait(delay);
6
+ if (state.value !== previous)
7
+ return state.value !== null;
8
+ let checked;
9
+ try {
10
+ checked = await fetchSession();
11
+ } catch {
12
+ return true;
13
+ }
14
+ if (state.value !== previous)
15
+ return state.value !== null;
16
+ if (checked) {
17
+ state.value = checked;
18
+ return true;
19
+ }
20
+ }
21
+ state.value = null;
22
+ return false;
23
+ }
@@ -32,5 +32,6 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
32
32
  name: string | null;
33
33
  picture: string | null;
34
34
  } | null;
35
+ expiresAt?: number | undefined;
35
36
  }>>;
36
37
  export default _default;
@@ -1,38 +1,58 @@
1
1
  import { createError, defineEventHandler, readValidatedBody } from "h3";
2
2
  import { z } from "zod";
3
+ import { UserinfoClaimsSchema } from "../../../../contract";
3
4
  import { idpFetch } from "../../utils/idp.js";
4
- import { newSessionId, readSessionRecord, toPublicSession, writeSessionRecord } from "../../utils/session.js";
5
- const bodySchema = z.object({ userId: z.string() });
5
+ import { requireSameOrigin } from "../../utils/oidc.js";
6
+ import { deleteSessionBackup, issueSession, newSessionId, readSessionRecord, setSessionRestoreCookie, toPublicSession, writeSessionRecord } from "../../utils/session.js";
7
+ const bodySchema = z.object({ userId: z.string().min(1) });
8
+ const responseSchema = z.object({
9
+ accessToken: z.string().min(1),
10
+ expiresAt: z.number().finite(),
11
+ user: z.object({ sub: z.string().min(1), email: z.email(), name: z.string().nullable().optional(), picture: z.string().nullable().optional() }),
12
+ claims: UserinfoClaimsSchema
13
+ });
6
14
  export default defineEventHandler(async (event) => {
7
- const s = await readSessionRecord(event);
8
- if (!s)
15
+ requireSameOrigin(event);
16
+ const session = await readSessionRecord(event);
17
+ if (!session)
9
18
  throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
10
- if (s.rec.isImpersonation)
19
+ if (session.rec.isImpersonation)
11
20
  throw createError({ statusCode: 400, statusMessage: "Already impersonating" });
21
+ if (session.rec.systemRole !== "admin")
22
+ throw createError({ statusCode: 403, statusMessage: "Forbidden" });
12
23
  const { userId } = await readValidatedBody(event, bodySchema.parse);
13
- const res = await idpFetch(event, s.id, s.rec, "/api/auth/rp/impersonate", {
24
+ const res = responseSchema.parse(await idpFetch(event, session.id, session.rec, "/api/auth/rp/impersonate", {
14
25
  method: "POST",
15
26
  body: { userId }
16
- });
27
+ }));
28
+ if (res.user.sub !== userId)
29
+ throw createError({ statusCode: 502, statusMessage: "Identity service returned a different user" });
17
30
  const backupId = newSessionId();
18
- await writeSessionRecord(backupId, { ...s.rec, isImpersonation: false });
19
- s.rec = {
20
- sub: res.user.sub,
21
- user: { sub: res.user.sub, email: res.user.email, name: res.user.name ?? null, picture: res.user.picture ?? null },
22
- abilities: res.claims.abilities,
23
- systemRole: null,
24
- organizations: res.claims.organizations,
25
- activeOrg: res.claims.org,
26
- entitlement: res.claims.entitlement,
27
- accessToken: res.accessToken,
28
- refreshToken: null,
29
- idToken: null,
30
- accessExpiresAt: res.expiresAt,
31
- authorizationRefreshedAt: Date.now(),
32
- isImpersonation: true,
33
- impersonator: { sub: s.rec.user.sub, email: s.rec.user.email, name: s.rec.user.name, picture: s.rec.user.picture },
34
- backupId
35
- };
36
- await writeSessionRecord(s.id, s.rec);
37
- return toPublicSession(s.rec);
31
+ await writeSessionRecord(backupId, session.rec);
32
+ try {
33
+ const organizationAuthorizations = { ...res.claims.organizationAuthorizations };
34
+ if (res.claims.org && !Object.hasOwn(organizationAuthorizations, res.claims.org))
35
+ organizationAuthorizations[res.claims.org] = res.claims.abilities;
36
+ const next = await issueSession(event, {
37
+ sub: res.user.sub,
38
+ user: { sub: res.user.sub, email: res.user.email, name: res.user.name ?? null, picture: res.user.picture ?? null },
39
+ abilities: res.claims.abilities,
40
+ organizationAuthorizations,
41
+ systemRole: null,
42
+ organizations: res.claims.organizations,
43
+ activeOrg: res.claims.org,
44
+ entitlement: res.claims.entitlement,
45
+ accessToken: res.accessToken,
46
+ accessExpiresAt: res.expiresAt,
47
+ expiresAt: Math.min(session.rec.expiresAt, res.expiresAt),
48
+ isImpersonation: true,
49
+ impersonator: session.rec.user,
50
+ backupId
51
+ }, { expectedPreviousId: session.id });
52
+ await setSessionRestoreCookie(event, backupId, session.rec);
53
+ return toPublicSession(next.rec);
54
+ } catch (error) {
55
+ await deleteSessionBackup(backupId);
56
+ throw error;
57
+ }
38
58
  });
@@ -32,5 +32,6 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
32
32
  name: string | null;
33
33
  picture: string | null;
34
34
  } | null;
35
+ expiresAt?: number | undefined;
35
36
  }>>;
36
37
  export default _default;
@@ -1,26 +1,21 @@
1
1
  import { createError, defineEventHandler, readValidatedBody } from "h3";
2
2
  import { z } from "zod";
3
- import { idpFetch } from "../../../utils/idp.js";
4
- import { readSessionRecord, toPublicSession, writeSessionRecord } from "../../../utils/session.js";
3
+ import { requireSameOrigin } from "../../../utils/oidc.js";
4
+ import { issueSession, readSessionRecord, toPublicSession } from "../../../utils/session.js";
5
5
  const bodySchema = z.object({ organizationId: z.string().min(1) });
6
6
  export default defineEventHandler(async (event) => {
7
+ requireSameOrigin(event);
7
8
  const session = await readSessionRecord(event);
8
9
  if (!session)
9
10
  throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
10
11
  const { organizationId } = await readValidatedBody(event, bodySchema.parse);
11
- const context = await idpFetch(
12
- event,
13
- session.id,
14
- session.rec,
15
- "/api/auth/rp/organizations/switch",
16
- { method: "POST", body: { organizationId } }
17
- );
18
- session.rec.activeOrg = context.organization.id;
19
- session.rec.organizations = context.organizations;
20
- session.rec.abilities = context.abilities;
21
- session.rec.systemRole = context.systemRole;
22
- session.rec.entitlement = context.entitlement;
23
- session.rec.authorizationRefreshedAt = Date.now();
24
- await writeSessionRecord(session.id, session.rec);
25
- return toPublicSession(session.rec);
12
+ if (!session.rec.organizations.some((organization) => organization.id === organizationId) || !Object.hasOwn(session.rec.organizationAuthorizations, organizationId)) {
13
+ throw createError({ statusCode: 403, statusMessage: "Sign in again to access this organization" });
14
+ }
15
+ const next = await issueSession(event, {
16
+ ...session.rec,
17
+ activeOrg: organizationId,
18
+ abilities: session.rec.organizationAuthorizations[organizationId]
19
+ }, { expectedPreviousId: session.id });
20
+ return toPublicSession(next.rec);
26
21
  });
@@ -0,0 +1,5 @@
1
+ /** Expose availability only; restoring identity requires the same-origin POST. */
2
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
3
+ available: boolean;
4
+ }>>;
5
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler, setResponseHeader } from "h3";
2
+ import { readSessionRestore } from "../../utils/session.js";
3
+ export default defineEventHandler(async (event) => {
4
+ setResponseHeader(event, "cache-control", "no-store");
5
+ return { available: Boolean(await readSessionRestore(event)) };
6
+ });
@@ -32,5 +32,6 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
32
32
  name: string | null;
33
33
  picture: string | null;
34
34
  } | null;
35
+ expiresAt?: number | undefined;
35
36
  } | null>>;
36
37
  export default _default;
@@ -1,7 +1,10 @@
1
1
  import { defineEventHandler } from "h3";
2
2
  import { setLogoutIntent } from "../../utils/logout-intent.js";
3
+ import { clearPendingAuthentication, requireSameOrigin } from "../../utils/oidc.js";
3
4
  import { destroySession } from "../../utils/session.js";
4
5
  export default defineEventHandler(async (event) => {
6
+ requireSameOrigin(event);
7
+ await clearPendingAuthentication(event);
5
8
  await destroySession(event);
6
9
  setLogoutIntent(event);
7
10
  return { ok: true };
@@ -32,5 +32,6 @@ declare const _default: import("h3").EventHandler<import("h3").EventHandlerReque
32
32
  name: string | null;
33
33
  picture: string | null;
34
34
  } | null;
35
+ expiresAt?: number | undefined;
35
36
  }>>;
36
37
  export default _default;
@@ -1,20 +1,22 @@
1
1
  import { createError, defineEventHandler } from "h3";
2
- import { useRuntimeConfig, useStorage } from "nitropack/runtime";
3
- import { idpFetch } from "../../utils/idp.js";
4
- import { readSessionRecord, readSessionRecordById, toPublicSession, writeSessionRecord } from "../../utils/session.js";
2
+ import { requireSameOrigin } from "../../utils/oidc.js";
3
+ import { clearSessionRestoreCookie, deleteSessionBackup, issueSession, readSessionRecord, readSessionRestore, toPublicSession } from "../../utils/session.js";
5
4
  export default defineEventHandler(async (event) => {
6
- const s = await readSessionRecord(event);
7
- if (!s || !s.rec.isImpersonation)
5
+ requireSameOrigin(event);
6
+ const session = await readSessionRecord(event);
7
+ const restore = await readSessionRestore(event);
8
+ if (session && !session.rec.isImpersonation)
8
9
  throw createError({ statusCode: 400, statusMessage: "Not impersonating" });
9
- try {
10
- await idpFetch(event, s.id, s.rec, "/api/auth/rp/stop-impersonating", { method: "POST" });
11
- } catch {
12
- }
13
- const backup = s.rec.backupId ? await readSessionRecordById(s.rec.backupId) : null;
14
- if (!backup)
15
- throw createError({ statusCode: 500, statusMessage: "Original session missing \u2014 sign in again" });
16
- await writeSessionRecord(s.id, backup);
17
- const { auth: runtimeConfig } = useRuntimeConfig();
18
- await useStorage(runtimeConfig.sessionStorageBase).removeItem(`session:${s.rec.backupId}`);
19
- return toPublicSession(backup);
10
+ if (!restore || session && (session.rec.backupId !== restore.id || session.rec.impersonator?.sub !== restore.rec.sub))
11
+ throw createError({ statusCode: 401, statusMessage: "Original session expired; sign in again" });
12
+ const next = await issueSession(event, restore.rec, {
13
+ expectedPreviousId: session?.id ?? null,
14
+ validate: async () => {
15
+ if ((await readSessionRestore(event))?.id !== restore.id)
16
+ throw createError({ statusCode: 409, statusMessage: "Original session was revoked" });
17
+ }
18
+ });
19
+ await deleteSessionBackup(restore.id);
20
+ clearSessionRestoreCookie(event);
21
+ return toPublicSession(next.rec);
20
22
  });
@@ -3,5 +3,5 @@ export type { ServerAbility } from './utils/ability.js';
3
3
  export { resolveOrganizationUser } from './utils/directory.js';
4
4
  export { defineAuthorizedHandler, defineSensitiveAuthorizedHandler, } from './utils/handlers.js';
5
5
  export { verifyOrganizationIntegrationKey } from './utils/integration-key.js';
6
- export { getFreshServerAuthSession, getServerAuthSession } from './utils/session.js';
7
- export type { ServerAuthSession } from './utils/session.js';
6
+ export { getFreshServerAuthSession, getServerAuthSession, issueSession } from './utils/session.js';
7
+ export type { ServerAuthSession, SessionInput, SessionRecord } from './utils/session.js';
@@ -10,4 +10,4 @@ export {
10
10
  defineSensitiveAuthorizedHandler
11
11
  } from "./utils/handlers.js";
12
12
  export { verifyOrganizationIntegrationKey } from "./utils/integration-key.js";
13
- export { getFreshServerAuthSession, getServerAuthSession } from "./utils/session.js";
13
+ export { getFreshServerAuthSession, getServerAuthSession, issueSession } from "./utils/session.js";