@thecodeorigin/auth 0.0.9 → 0.0.11

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 +14 -9
@@ -0,0 +1,43 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { createAuthEndpoint } from "better-auth/api";
3
+ import { setSessionCookie } from "better-auth/cookies";
4
+ export function createLocalAuth(options, input) {
5
+ return betterAuth({
6
+ ...options,
7
+ account: { storeAccountCookie: false },
8
+ session: {
9
+ ...options.session,
10
+ disableSessionRefresh: true,
11
+ storeSessionInDatabase: false,
12
+ cookieCache: { enabled: false },
13
+ additionalFields: { snapshot: { type: "string", required: true, input: false } }
14
+ },
15
+ plugins: [{
16
+ id: "application-session",
17
+ endpoints: {
18
+ // Called only via auth.api after verification; auth.handler is never exposed.
19
+ establishLocalSession: createAuthEndpoint("/internal-establish-local-session", { method: "POST" }, async (ctx) => {
20
+ if (!input || input.expiresAt <= Date.now())
21
+ throw new Error("A verified, unexpired identity is required");
22
+ const user = await ctx.context.internalAdapter.createUser({
23
+ id: input.user.sub,
24
+ email: input.user.email,
25
+ emailVerified: true,
26
+ name: input.user.name ?? input.user.email,
27
+ image: input.user.picture
28
+ });
29
+ const session = await ctx.context.internalAdapter.createSession(user.id, false, {
30
+ snapshot: input.snapshot,
31
+ expiresAt: new Date(input.expiresAt)
32
+ }, true);
33
+ if (!session)
34
+ throw new Error("Unable to create application session");
35
+ await setSessionCookie(ctx, { session, user }, false, {
36
+ maxAge: Math.max(1, Math.floor((input.expiresAt - Date.now()) / 1e3))
37
+ });
38
+ return ctx.json({ token: session.token });
39
+ })
40
+ }
41
+ }]
42
+ });
43
+ }
@@ -3,6 +3,8 @@ declare module 'nitropack' {
3
3
  interface NitroRuntimeConfig {
4
4
  auth: {
5
5
  clientSecret: string;
6
+ sessionSecret: string;
7
+ sessionMaxAge: number;
6
8
  sessionStorageBase: string;
7
9
  sessionCookieName: string;
8
10
  logoutIntentCookieName: string;
@@ -37,5 +39,24 @@ export declare function exchangeCode(code: string, verifier: string, redirectUri
37
39
  id_token?: string;
38
40
  }>;
39
41
  export declare function fetchUserinfo(accessToken: string): Promise<any>;
40
- /** Same-origin, path-only redirect target (prevents open redirect). */
41
- export declare function safePath(target: string | undefined, fallback: string): string;
42
+ /** Same-origin, path-only redirect target, including encoded backslash/control defenses. */
43
+ export declare function safePath(target: unknown, fallback: string): string;
44
+ export declare function requireSameOrigin(event: H3Event): void;
45
+ export declare function transactionCookieName(state: string): string;
46
+ export declare function encodeTransaction(input: {
47
+ state: string;
48
+ verifier: string;
49
+ nonce: string;
50
+ redirect: string;
51
+ flowId: string;
52
+ }): Promise<string>;
53
+ export declare function decodeTransaction(value: string, state: string): Promise<{
54
+ verifier: string;
55
+ nonce: string;
56
+ redirect: string;
57
+ flowId: string;
58
+ }>;
59
+ export declare function verifyIdToken(idToken: string, nonce: string, subject: string): Promise<void>;
60
+ export declare function beginAuthentication(event: H3Event, state: string): Promise<string>;
61
+ export declare function assertPendingAuthentication(event: H3Event, state: string, flowId: string): Promise<void>;
62
+ export declare function clearPendingAuthentication(event: H3Event, expectedState?: string): Promise<void>;
@@ -1,6 +1,8 @@
1
- import { getRequestHost, getRequestProtocol } from "h3";
2
- import { useRuntimeConfig } from "nitropack/runtime";
1
+ import { createError, deleteCookie, getCookie, getHeader, getRequestHost, getRequestProtocol, getRequestURL, setCookie, setResponseHeader } from "h3";
2
+ import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
3
+ import { useRuntimeConfig, useStorage } from "nitropack/runtime";
3
4
  import { $fetch } from "ofetch";
5
+ import { sessionSecret } from "./session.js";
4
6
  function b64url(buf) {
5
7
  return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6
8
  }
@@ -19,7 +21,7 @@ export function callbackRedirectUri(event) {
19
21
  export function idpBaseUrl() {
20
22
  const { public: { auth: publicRuntimeConfig } } = useRuntimeConfig();
21
23
  const domain = publicRuntimeConfig.domain;
22
- return /^https?:\/\//.test(domain) ? domain : `https://${domain}`;
24
+ return (/^https?:\/\//.test(domain) ? domain : `https://${domain}`).replace(/\/$/, "");
23
25
  }
24
26
  export async function exchangeCode(code, verifier, redirectUri) {
25
27
  const { auth: runtimeConfig, public: { auth: publicRuntimeConfig } } = useRuntimeConfig();
@@ -27,6 +29,8 @@ export async function exchangeCode(code, verifier, redirectUri) {
27
29
  `${idpBaseUrl()}/api/auth/oauth2/token`,
28
30
  {
29
31
  method: "POST",
32
+ timeout: 1e4,
33
+ retry: 0,
30
34
  headers: {
31
35
  "Content-Type": "application/x-www-form-urlencoded",
32
36
  "Authorization": `Basic ${btoa(`${publicRuntimeConfig.clientId}:${runtimeConfig.clientSecret}`)}`
@@ -42,11 +46,84 @@ export async function exchangeCode(code, verifier, redirectUri) {
42
46
  }
43
47
  export async function fetchUserinfo(accessToken) {
44
48
  return $fetch(`${idpBaseUrl()}/api/auth/oauth2/userinfo`, {
49
+ timeout: 1e4,
50
+ retry: 0,
45
51
  headers: { Authorization: `Bearer ${accessToken}` }
46
52
  });
47
53
  }
48
54
  export function safePath(target, fallback) {
49
- if (!target || !target.startsWith("/") || target.startsWith("//"))
55
+ if (typeof target !== "string" || !target.startsWith("/") || target.startsWith("//"))
50
56
  return fallback;
51
- return target;
57
+ try {
58
+ const decoded = decodeURIComponent(target);
59
+ if (decoded.includes("\\") || Array.from(decoded).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || decoded.startsWith("//"))
60
+ return fallback;
61
+ const url = new URL(target, "https://local.invalid");
62
+ return url.origin === "https://local.invalid" ? `${url.pathname}${url.search}${url.hash}` : fallback;
63
+ } catch {
64
+ return fallback;
65
+ }
66
+ }
67
+ export function requireSameOrigin(event) {
68
+ setResponseHeader(event, "cache-control", "no-store");
69
+ const origin = getHeader(event, "origin");
70
+ if (!origin || origin !== getRequestURL(event).origin || getHeader(event, "sec-fetch-site") === "cross-site")
71
+ throw createError({ statusCode: 403, statusMessage: "Same-origin request required" });
72
+ }
73
+ export function transactionCookieName(state) {
74
+ return `${useRuntimeConfig().auth.sessionCookieName}_oidc_${state}`;
75
+ }
76
+ export async function encodeTransaction(input) {
77
+ return new SignJWT(input).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("10m").sign(new TextEncoder().encode(await sessionSecret()));
78
+ }
79
+ export async function decodeTransaction(value, state) {
80
+ const { payload } = await jwtVerify(value, new TextEncoder().encode(await sessionSecret()), {
81
+ algorithms: ["HS256"],
82
+ requiredClaims: ["exp", "iat"]
83
+ });
84
+ if (payload.state !== state || typeof payload.verifier !== "string" || typeof payload.nonce !== "string" || typeof payload.redirect !== "string" || typeof payload.flowId !== "string")
85
+ throw new Error("Invalid OAuth transaction");
86
+ return { verifier: payload.verifier, nonce: payload.nonce, redirect: payload.redirect, flowId: payload.flowId };
87
+ }
88
+ export async function verifyIdToken(idToken, nonce, subject) {
89
+ const { public: { auth } } = useRuntimeConfig();
90
+ const issuer = `${idpBaseUrl()}/api/auth`;
91
+ const keys = createRemoteJWKSet(new URL(`${issuer}/jwks`), { timeoutDuration: 1e4 });
92
+ const { payload } = await jwtVerify(idToken, keys, {
93
+ issuer,
94
+ audience: auth.clientId,
95
+ algorithms: ["RS256"],
96
+ requiredClaims: ["sub", "exp", "iat", "nonce"]
97
+ });
98
+ if (payload.nonce !== nonce || payload.sub !== subject || payload.azp !== void 0 && payload.azp !== auth.clientId || Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== auth.clientId) {
99
+ throw new Error("ID token identity binding failed");
100
+ }
101
+ }
102
+ function flowCookieName() {
103
+ return `${useRuntimeConfig().auth.sessionCookieName}_oidc_latest`;
104
+ }
105
+ function flowKey(flowId) {
106
+ const config = useRuntimeConfig();
107
+ return `auth:${encodeURIComponent(config.public.auth.clientId)}:${config.auth.sessionCookieName}:oauth-flow:${flowId}`;
108
+ }
109
+ export async function beginAuthentication(event, state) {
110
+ const existing = getCookie(event, flowCookieName());
111
+ const flowId = existing && /^[\w-]{32}$/.test(existing) ? existing : randomString(32);
112
+ await useStorage(useRuntimeConfig().auth.sessionStorageBase).setItem(flowKey(flowId), state, { ttl: 600 });
113
+ setCookie(event, flowCookieName(), flowId, { httpOnly: true, secure: getRequestURL(event).protocol === "https:", sameSite: "lax", path: "/", maxAge: 600 });
114
+ return flowId;
115
+ }
116
+ export async function assertPendingAuthentication(event, state, flowId) {
117
+ if (getCookie(event, flowCookieName()) !== flowId || await useStorage(useRuntimeConfig().auth.sessionStorageBase).getItem(flowKey(flowId)) !== state) {
118
+ throw createError({ statusCode: 409, statusMessage: "Authentication request was canceled or replaced" });
119
+ }
120
+ }
121
+ export async function clearPendingAuthentication(event, expectedState) {
122
+ const flowId = getCookie(event, flowCookieName());
123
+ const storage = useStorage(useRuntimeConfig().auth.sessionStorageBase);
124
+ if (expectedState && (!flowId || await storage.getItem(flowKey(flowId)) !== expectedState))
125
+ return;
126
+ if (flowId)
127
+ await storage.removeItem(flowKey(flowId));
128
+ deleteCookie(event, flowCookieName(), { path: "/" });
52
129
  }
@@ -1,6 +1,5 @@
1
1
  import type { H3Event } from 'h3';
2
2
  import type { AbilityRule, PublicSession, ServerAuthSession as ServerAuthSessionContract } from '../../../contract/index.js';
3
- export declare const AUTHORIZATION_TTL_MS = 120000;
4
3
  export interface SessionRecord {
5
4
  sub: string;
6
5
  user: {
@@ -10,15 +9,15 @@ export interface SessionRecord {
10
9
  picture: string | null;
11
10
  };
12
11
  abilities: AbilityRule[];
12
+ organizationAuthorizations: Record<string, AbilityRule[]>;
13
13
  systemRole: string | null;
14
14
  organizations: PublicSession['organizations'];
15
15
  activeOrg: string | null;
16
16
  entitlement: PublicSession['entitlement'];
17
+ /** Access credential is used only for explicit directory/support operations. */
17
18
  accessToken: string;
18
- refreshToken: string | null;
19
- idToken: string | null;
20
19
  accessExpiresAt: number;
21
- authorizationRefreshedAt: number;
20
+ expiresAt: number;
22
21
  isImpersonation: boolean;
23
22
  impersonator: {
24
23
  sub: string;
@@ -28,21 +27,43 @@ export interface SessionRecord {
28
27
  } | null;
29
28
  backupId: string | null;
30
29
  }
30
+ export type SessionInput = Omit<SessionRecord, 'expiresAt'> & {
31
+ expiresAt?: number;
32
+ };
31
33
  export declare function newSessionId(): string;
32
- export declare function writeSessionRecord(id: string, rec: SessionRecord): Promise<void>;
34
+ export declare function sessionSecret(): Promise<string>;
35
+ export declare function requestHeaders(event: H3Event): Headers;
36
+ /** Establish a new opaque session and revoke the previous browser session. */
37
+ export declare function issueSession(event: H3Event, input: SessionInput, options?: {
38
+ expectedPreviousId?: string | null;
39
+ validate?: () => Promise<void>;
40
+ }): Promise<{
41
+ id: string;
42
+ rec: SessionRecord;
43
+ }>;
33
44
  export declare function readSessionRecord(event: H3Event): Promise<{
34
45
  id: string;
35
46
  rec: SessionRecord;
36
47
  } | null>;
37
- export declare function setSessionCookie(event: H3Event, id: string): Promise<void>;
38
- export declare function destroySession(event: H3Event): Promise<SessionRecord | null>;
48
+ /** Private backup records never act as browser sessions and retain original expiry. */
49
+ export declare function writeSessionRecord(id: string, rec: SessionRecord): Promise<void>;
39
50
  export declare function readSessionRecordById(id: string): Promise<SessionRecord | null>;
51
+ export declare function deleteSessionBackup(id: string): Promise<void>;
52
+ /** A separate original-session capability remains usable after the support session expires. */
53
+ export declare function setSessionRestoreCookie(event: H3Event, backupId: string, original: SessionRecord): Promise<void>;
54
+ export declare function clearSessionRestoreCookie(event: H3Event): void;
55
+ export declare function readSessionRestore(event: H3Event): Promise<{
56
+ id: string;
57
+ rec: SessionRecord;
58
+ } | null>;
59
+ export declare function destroySession(event: H3Event): Promise<SessionRecord | null>;
40
60
  export type ServerAuthSession = ServerAuthSessionContract;
41
61
  /** Server-safe session projection — no tokens or private refresh metadata. */
42
62
  export declare function toServerAuthSession(rec: SessionRecord): ServerAuthSession;
43
- /** Server-safe session for callers that explicitly do not need a live refresh. */
63
+ /** Every application authorization read uses its own fixed-expiry snapshot. */
44
64
  export declare function getServerAuthSession(event: H3Event): Promise<ServerAuthSession | null>;
45
- export declare function getFreshServerAuthSession(event: H3Event, options?: {
65
+ /** Kept for API compatibility; freshness means local expiry, never an ID refresh. */
66
+ export declare function getFreshServerAuthSession(event: H3Event, _options?: {
46
67
  force?: boolean;
47
68
  }): Promise<ServerAuthSession | null>;
48
69
  /** Browser-safe projection — tokens are excluded. */
@@ -1,53 +1,207 @@
1
- import { createError, deleteCookie, getCookie, setCookie } from "h3";
1
+ import { appendResponseHeader, createError, deleteCookie, getCookie, getRequestHeaders, getRequestURL, setCookie, setResponseHeader } from "h3";
2
+ import { jwtVerify, SignJWT } from "jose";
2
3
  import { useRuntimeConfig, useStorage } from "nitropack/runtime";
3
- import { refreshAuthorizationContext } from "./idp.js";
4
- export const AUTHORIZATION_TTL_MS = 12e4;
5
- function key(id) {
6
- return `session:${id}`;
4
+ import { z } from "zod";
5
+ import { AbilityRuleSchema, PublicUserSchema, RpOrganizationSchema, UserinfoClaimsSchema } from "../../../contract";
6
+ import { createLocalAuth } from "./local-auth.js";
7
+ const SessionRecordSchema = z.object({
8
+ sub: z.string().min(1),
9
+ user: PublicUserSchema,
10
+ abilities: z.array(AbilityRuleSchema),
11
+ organizationAuthorizations: z.record(z.string(), z.array(AbilityRuleSchema)),
12
+ systemRole: z.string().nullable(),
13
+ organizations: z.array(RpOrganizationSchema),
14
+ activeOrg: z.string().nullable(),
15
+ entitlement: UserinfoClaimsSchema.shape.entitlement,
16
+ accessToken: z.string(),
17
+ accessExpiresAt: z.number().finite(),
18
+ expiresAt: z.number().finite(),
19
+ isImpersonation: z.boolean(),
20
+ impersonator: PublicUserSchema.nullable(),
21
+ backupId: z.string().nullable()
22
+ });
23
+ function parseSessionRecord(value) {
24
+ const parsed = SessionRecordSchema.safeParse(value);
25
+ return parsed.success && parsed.data.expiresAt > Date.now() && parsed.data.sub === parsed.data.user.sub ? parsed.data : null;
7
26
  }
8
27
  export function newSessionId() {
9
- const a = new Uint8Array(32);
10
- crypto.getRandomValues(a);
11
- return [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
28
+ return crypto.randomUUID();
12
29
  }
13
- export async function writeSessionRecord(id, rec) {
14
- const { auth: runtimeConfig } = useRuntimeConfig();
15
- await useStorage(runtimeConfig.sessionStorageBase).setItem(key(id), rec);
30
+ function namespace() {
31
+ const config = useRuntimeConfig();
32
+ return `auth:${encodeURIComponent(config.public.auth.clientId)}:${config.auth.sessionCookieName}:`;
33
+ }
34
+ function storage() {
35
+ return useStorage(useRuntimeConfig().auth.sessionStorageBase);
36
+ }
37
+ export async function sessionSecret() {
38
+ const { auth, public: { auth: publicAuth } } = useRuntimeConfig();
39
+ if (auth.sessionSecret)
40
+ return auth.sessionSecret;
41
+ if (!auth.clientSecret)
42
+ throw createError({ statusCode: 503, statusMessage: "Application session secret is not configured" });
43
+ const encoder = new TextEncoder();
44
+ const key = await crypto.subtle.importKey("raw", encoder.encode(auth.clientSecret), "HKDF", false, ["deriveBits"]);
45
+ const bits = await crypto.subtle.deriveBits({
46
+ name: "HKDF",
47
+ hash: "SHA-256",
48
+ salt: encoder.encode("thecodeorigin.application-session.v1"),
49
+ info: encoder.encode(`${publicAuth.clientId}:${auth.sessionCookieName}`)
50
+ }, key, 256);
51
+ return Array.from(new Uint8Array(bits), (byte) => byte.toString(16).padStart(2, "0")).join("");
52
+ }
53
+ export function requestHeaders(event) {
54
+ const headers = new Headers();
55
+ for (const [name, value] of Object.entries(getRequestHeaders(event))) {
56
+ if (value !== void 0)
57
+ headers.set(name, value);
58
+ }
59
+ return headers;
60
+ }
61
+ async function localAuth(event, rec) {
62
+ const { auth } = useRuntimeConfig();
63
+ const origin = getRequestURL(event).origin;
64
+ const prefix = namespace();
65
+ return createLocalAuth({
66
+ baseURL: origin,
67
+ secret: await sessionSecret(),
68
+ advanced: {
69
+ cookiePrefix: auth.sessionCookieName,
70
+ useSecureCookies: origin.startsWith("https:"),
71
+ cookies: { session_token: { name: auth.sessionCookieName } },
72
+ crossSubDomainCookies: { enabled: false }
73
+ },
74
+ session: { expiresIn: auth.sessionMaxAge },
75
+ secondaryStorage: {
76
+ get: async (key) => await storage().getItem(`${prefix}${key}`),
77
+ set: async (key, value, ttl) => {
78
+ await storage().setItem(`${prefix}${key}`, value, ttl ? { ttl } : void 0);
79
+ },
80
+ delete: async (key) => {
81
+ await storage().removeItem(`${prefix}${key}`);
82
+ }
83
+ }
84
+ }, rec ? { user: rec.user, expiresAt: rec.expiresAt, snapshot: JSON.stringify(rec) } : void 0);
85
+ }
86
+ export async function issueSession(event, input, options = {}) {
87
+ const maxExpiry = Date.now() + useRuntimeConfig().auth.sessionMaxAge * 1e3;
88
+ const rec = { ...input, expiresAt: Math.min(input.expiresAt ?? maxExpiry, maxExpiry) };
89
+ if (!Number.isFinite(rec.expiresAt) || rec.expiresAt <= Date.now())
90
+ throw createError({ statusCode: 401, statusMessage: "Session expired; sign in again" });
91
+ const previous = await readSessionRecord(event);
92
+ if (options.expectedPreviousId !== void 0 && (previous?.id ?? null) !== options.expectedPreviousId)
93
+ throw createError({ statusCode: 409, statusMessage: "Session changed; reload before continuing" });
94
+ await options.validate?.();
95
+ const restore = await readSessionRestore(event);
96
+ const auth = await localAuth(event, rec);
97
+ const { headers, response } = await auth.api.establishLocalSession({
98
+ headers: requestHeaders(event),
99
+ returnHeaders: true
100
+ });
101
+ try {
102
+ if (options.expectedPreviousId !== void 0 && ((await readSessionRecord(event))?.id ?? null) !== options.expectedPreviousId)
103
+ throw createError({ statusCode: 409, statusMessage: "Session changed; reload before continuing" });
104
+ await options.validate?.();
105
+ const context = await auth.$context;
106
+ if (previous)
107
+ await context.internalAdapter.deleteSession(previous.id);
108
+ if (restore && restore.id !== rec.backupId) {
109
+ await deleteSessionBackup(restore.id);
110
+ clearSessionRestoreCookie(event);
111
+ }
112
+ } catch (error) {
113
+ const context = await auth.$context;
114
+ await context.internalAdapter.deleteSession(response.token);
115
+ throw error;
116
+ }
117
+ for (const cookie of headers.getSetCookie())
118
+ appendResponseHeader(event, "set-cookie", cookie);
119
+ return { id: response.token, rec };
16
120
  }
17
121
  export async function readSessionRecord(event) {
18
- const { auth: runtimeConfig } = useRuntimeConfig();
19
- const id = getCookie(event, runtimeConfig.sessionCookieName);
20
- if (!id)
122
+ setResponseHeader(event, "cache-control", "no-store");
123
+ const auth = await localAuth(event);
124
+ const result = await auth.api.getSession({ headers: requestHeaders(event) });
125
+ if (!result)
21
126
  return null;
22
- const rec = await useStorage(runtimeConfig.sessionStorageBase).getItem(key(id));
23
- return rec ? { id, rec } : null;
127
+ const snapshot = result.session.snapshot;
128
+ if (typeof snapshot !== "string")
129
+ return null;
130
+ try {
131
+ const rec = parseSessionRecord(JSON.parse(snapshot));
132
+ return rec && rec.sub === result.user.id ? { id: result.session.token, rec } : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ export async function writeSessionRecord(id, rec) {
138
+ const ttl = Math.floor((rec.expiresAt - Date.now()) / 1e3);
139
+ if (ttl <= 0)
140
+ throw createError({ statusCode: 401, statusMessage: "Original session expired" });
141
+ await storage().setItem(`${namespace()}backup:${id}`, rec, { ttl });
24
142
  }
25
- export async function setSessionCookie(event, id) {
26
- const { auth: runtimeConfig } = useRuntimeConfig();
27
- setCookie(event, runtimeConfig.sessionCookieName, id, {
143
+ export async function readSessionRecordById(id) {
144
+ const rec = await storage().getItem(`${namespace()}backup:${id}`);
145
+ return parseSessionRecord(rec);
146
+ }
147
+ export async function deleteSessionBackup(id) {
148
+ await storage().removeItem(`${namespace()}backup:${id}`);
149
+ }
150
+ function restoreCookieName() {
151
+ return `${useRuntimeConfig().auth.sessionCookieName}_restore`;
152
+ }
153
+ export async function setSessionRestoreCookie(event, backupId, original) {
154
+ const token = await new SignJWT({ backupId, sub: original.sub, purpose: "restore-application-session" }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(Math.floor(original.expiresAt / 1e3)).sign(new TextEncoder().encode(await sessionSecret()));
155
+ setCookie(event, restoreCookieName(), token, {
28
156
  httpOnly: true,
29
- secure: !import.meta.dev,
157
+ secure: getRequestURL(event).protocol === "https:",
30
158
  sameSite: "lax",
31
159
  path: "/",
32
- maxAge: 60 * 60 * 24 * 7
160
+ maxAge: Math.max(1, Math.floor((original.expiresAt - Date.now()) / 1e3))
33
161
  });
34
162
  }
35
- export async function destroySession(event) {
36
- const { auth: runtimeConfig } = useRuntimeConfig();
37
- const id = getCookie(event, runtimeConfig.sessionCookieName);
38
- if (!id)
163
+ export function clearSessionRestoreCookie(event) {
164
+ deleteCookie(event, restoreCookieName(), { path: "/" });
165
+ }
166
+ export async function readSessionRestore(event) {
167
+ const value = getCookie(event, restoreCookieName());
168
+ if (!value)
169
+ return null;
170
+ let backupId;
171
+ let subject;
172
+ try {
173
+ const { payload } = await jwtVerify(value, new TextEncoder().encode(await sessionSecret()), { algorithms: ["HS256"], requiredClaims: ["exp", "iat", "sub"] });
174
+ if (payload.purpose !== "restore-application-session" || typeof payload.backupId !== "string" || typeof payload.sub !== "string")
175
+ return null;
176
+ backupId = payload.backupId;
177
+ subject = payload.sub;
178
+ } catch {
39
179
  return null;
40
- const rec = await useStorage(runtimeConfig.sessionStorageBase).getItem(key(id));
41
- await useStorage(runtimeConfig.sessionStorageBase).removeItem(key(id));
42
- deleteCookie(event, runtimeConfig.sessionCookieName, { path: "/" });
43
- return rec;
180
+ }
181
+ const rec = await readSessionRecordById(backupId);
182
+ return rec && rec.sub === subject && !rec.isImpersonation ? { id: backupId, rec } : null;
44
183
  }
45
- export async function readSessionRecordById(id) {
46
- const { auth: runtimeConfig } = useRuntimeConfig();
47
- return useStorage(runtimeConfig.sessionStorageBase).getItem(key(id));
184
+ export async function destroySession(event) {
185
+ const session = await readSessionRecord(event);
186
+ const restore = await readSessionRestore(event);
187
+ if (restore)
188
+ await deleteSessionBackup(restore.id);
189
+ clearSessionRestoreCookie(event);
190
+ const auth = await localAuth(event);
191
+ if (session) {
192
+ const context = await auth.$context;
193
+ await context.internalAdapter.deleteSession(session.id);
194
+ if (session.rec.backupId)
195
+ await deleteSessionBackup(session.rec.backupId);
196
+ }
197
+ const response = await auth.api.signOut({ headers: requestHeaders(event), asResponse: true });
198
+ for (const cookie of response.headers.getSetCookie())
199
+ appendResponseHeader(event, "set-cookie", cookie);
200
+ return session?.rec ?? null;
48
201
  }
49
202
  export function toServerAuthSession(rec) {
50
203
  return {
204
+ expiresAt: rec.expiresAt,
51
205
  sub: rec.sub,
52
206
  email: rec.user.email,
53
207
  name: rec.user.name,
@@ -65,38 +219,12 @@ export async function getServerAuthSession(event) {
65
219
  const session = await readSessionRecord(event);
66
220
  return session ? toServerAuthSession(session.rec) : null;
67
221
  }
68
- export async function getFreshServerAuthSession(event, options = {}) {
69
- const session = await readSessionRecord(event);
70
- if (!session)
71
- return null;
72
- if (!options.force && Date.now() - session.rec.authorizationRefreshedAt <= AUTHORIZATION_TTL_MS)
73
- return toServerAuthSession(session.rec);
74
- try {
75
- const context = await refreshAuthorizationContext(event, session.id, session.rec);
76
- session.rec.activeOrg = context.organization.id;
77
- session.rec.organizations = context.organizations;
78
- session.rec.abilities = context.abilities;
79
- session.rec.systemRole = context.systemRole;
80
- session.rec.entitlement = context.entitlement;
81
- session.rec.authorizationRefreshedAt = Date.now();
82
- await writeSessionRecord(session.id, session.rec);
83
- return toServerAuthSession(session.rec);
84
- } catch (error) {
85
- const status = error.statusCode ?? error.response?.status;
86
- if (status === 401 || status === 403) {
87
- session.rec.abilities = [];
88
- session.rec.organizations = [];
89
- session.rec.activeOrg = null;
90
- session.rec.systemRole = null;
91
- session.rec.authorizationRefreshedAt = Date.now();
92
- await writeSessionRecord(session.id, session.rec);
93
- throw createError({ statusCode: 403, statusMessage: "Authorization changed; sign in again" });
94
- }
95
- throw createError({ statusCode: 503, statusMessage: "Authorization service unavailable" });
96
- }
222
+ export async function getFreshServerAuthSession(event, _options = {}) {
223
+ return getServerAuthSession(event);
97
224
  }
98
225
  export function toPublicSession(rec) {
99
226
  return {
227
+ expiresAt: rec.expiresAt,
100
228
  user: rec.user,
101
229
  abilities: rec.abilities,
102
230
  systemRole: rec.systemRole,
@@ -1,4 +1,10 @@
1
+ import type { MongoAbility } from '@casl/ability'
2
+
1
3
  declare module '#app' {
4
+ interface NuxtApp {
5
+ $ability: MongoAbility
6
+ }
7
+
2
8
  interface PageMeta {
3
9
  public?: boolean
4
10
  unauthenticatedOnly?: boolean
@@ -6,6 +12,12 @@ declare module '#app' {
6
12
  }
7
13
  }
8
14
 
15
+ declare module 'vue' {
16
+ interface ComponentCustomProperties {
17
+ $ability: MongoAbility
18
+ }
19
+ }
20
+
9
21
  declare module 'vue-router' {
10
22
  interface RouteMeta {
11
23
  public?: boolean
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thecodeorigin/auth",
3
3
  "type": "module",
4
- "version": "0.0.9",
4
+ "version": "0.0.11",
5
5
  "private": false,
6
6
  "description": "THECODEORIGIN Authentication Portal client module",
7
7
  "repository": "https://github.com/thecodeorigin/auth",
@@ -47,17 +47,20 @@
47
47
  "lint": "eslint .",
48
48
  "test": "vitest run",
49
49
  "test:watch": "vitest watch",
50
+ "pretest:types": "pnpm dev:prepare",
50
51
  "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
51
52
  },
52
53
  "dependencies": {
53
- "@casl/ability": "catalog:",
54
- "@casl/vue": "catalog:",
55
- "@nuxt/kit": "catalog:",
56
- "defu": "catalog:",
57
- "h3": "catalog:",
58
- "ofetch": "catalog:",
59
- "ufo": "catalog:",
60
- "zod": "catalog:"
54
+ "@casl/ability": "^6.8.1",
55
+ "@casl/vue": "^2.2.6",
56
+ "@nuxt/kit": "^4.4.8",
57
+ "better-auth": "catalog:",
58
+ "defu": "^6.1.4",
59
+ "h3": "^1.14.0",
60
+ "jose": "catalog:",
61
+ "ofetch": "^1.4.1",
62
+ "ufo": "^1.5.4",
63
+ "zod": "^4.3.6"
61
64
  },
62
65
  "devDependencies": {
63
66
  "@antfu/eslint-config": "catalog:",
@@ -69,10 +72,12 @@
69
72
  "@types/node": "catalog:",
70
73
  "changelogen": "catalog:",
71
74
  "eslint": "catalog:",
75
+ "nitropack": "catalog:",
72
76
  "nuxt": "catalog:",
73
77
  "typescript": "catalog:",
74
78
  "unbuild": "catalog:",
75
79
  "vitest": "catalog:",
80
+ "vue": "catalog:",
76
81
  "vue-tsc": "catalog:"
77
82
  }
78
83
  }