@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.
- package/README.md +137 -72
- package/dist/contract/index.d.mts +80 -3
- package/dist/contract/index.d.ts +80 -3
- package/dist/contract/index.mjs +34 -2
- package/dist/module.d.mts +4 -0
- package/dist/module.d.ts +4 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +33 -12
- package/dist/runtime/app/composables/useAuth.d.ts +5 -0
- package/dist/runtime/app/composables/useAuth.js +50 -10
- package/dist/runtime/app/composables/useAuthLogin.d.ts +11 -0
- package/dist/runtime/app/composables/useAuthLogin.js +73 -0
- package/dist/runtime/app/middleware/auth.global.js +2 -0
- package/dist/runtime/app/plugins/session-sync.client.d.ts +2 -0
- package/dist/runtime/app/plugins/session-sync.client.js +47 -0
- package/dist/runtime/app/utils/return-path.d.ts +1 -0
- package/dist/runtime/app/utils/return-path.js +18 -0
- package/dist/runtime/app/utils/session-recovery.d.ts +5 -0
- package/dist/runtime/app/utils/session-recovery.js +23 -0
- package/dist/runtime/server/api/_auth/impersonate.post.d.ts +1 -0
- package/dist/runtime/server/api/_auth/impersonate.post.js +47 -27
- package/dist/runtime/server/api/_auth/organizations/switch.post.d.ts +1 -0
- package/dist/runtime/server/api/_auth/organizations/switch.post.js +12 -17
- package/dist/runtime/server/api/_auth/session-restore.get.d.ts +5 -0
- package/dist/runtime/server/api/_auth/session-restore.get.js +6 -0
- package/dist/runtime/server/api/_auth/session.get.d.ts +1 -0
- package/dist/runtime/server/api/_auth/sign-out.post.js +3 -0
- package/dist/runtime/server/api/_auth/stop-impersonating.post.d.ts +1 -0
- package/dist/runtime/server/api/_auth/stop-impersonating.post.js +18 -16
- package/dist/runtime/server/index.d.ts +2 -2
- package/dist/runtime/server/index.js +1 -1
- package/dist/runtime/server/routes/callback.get.js +62 -32
- package/dist/runtime/server/routes/sign-in.get.js +8 -6
- package/dist/runtime/server/routes/sign-out.get.d.ts +1 -0
- package/dist/runtime/server/routes/sign-out.get.js +4 -8
- package/dist/runtime/server/utils/idp.d.ts +2 -7
- package/dist/runtime/server/utils/idp.js +16 -64
- package/dist/runtime/server/utils/integration-key.d.ts +18 -0
- package/dist/runtime/server/utils/local-auth.d.ts +351 -0
- package/dist/runtime/server/utils/local-auth.js +43 -0
- package/dist/runtime/server/utils/oidc.d.ts +23 -2
- package/dist/runtime/server/utils/oidc.js +82 -5
- package/dist/runtime/server/utils/session.d.ts +30 -9
- package/dist/runtime/server/utils/session.js +189 -61
- package/dist/runtime/types.d.ts +12 -0
- package/package.json +9 -33
|
@@ -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
|
|
41
|
-
export declare function safePath(target:
|
|
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 {
|
|
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 (
|
|
55
|
+
if (typeof target !== "string" || !target.startsWith("/") || target.startsWith("//"))
|
|
50
56
|
return fallback;
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
export declare function
|
|
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
|
-
/**
|
|
63
|
+
/** Every application authorization read uses its own fixed-expiry snapshot. */
|
|
44
64
|
export declare function getServerAuthSession(event: H3Event): Promise<ServerAuthSession | null>;
|
|
45
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
crypto.getRandomValues(a);
|
|
11
|
-
return [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
return crypto.randomUUID();
|
|
12
29
|
}
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
|
23
|
-
|
|
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
|
|
26
|
-
const
|
|
27
|
-
|
|
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:
|
|
157
|
+
secure: getRequestURL(event).protocol === "https:",
|
|
30
158
|
sameSite: "lax",
|
|
31
159
|
path: "/",
|
|
32
|
-
maxAge:
|
|
160
|
+
maxAge: Math.max(1, Math.floor((original.expiresAt - Date.now()) / 1e3))
|
|
33
161
|
});
|
|
34
162
|
}
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
await
|
|
42
|
-
|
|
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
|
|
46
|
-
const
|
|
47
|
-
|
|
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,
|
|
69
|
-
|
|
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,
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thecodeorigin/auth",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.12",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "THECODEORIGIN Authentication Portal client module",
|
|
7
|
-
"repository":
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/thecodeorigin/auth"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"types": "./dist/types.d.mts",
|
|
@@ -39,43 +42,16 @@
|
|
|
39
42
|
"workspaces": [
|
|
40
43
|
"playground"
|
|
41
44
|
],
|
|
42
|
-
"
|
|
43
|
-
"prepack": "nuxt-module-build build",
|
|
44
|
-
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
45
|
-
"dev:build": "nuxt build playground",
|
|
46
|
-
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
47
|
-
"lint": "eslint .",
|
|
48
|
-
"test": "vitest run",
|
|
49
|
-
"test:watch": "vitest watch",
|
|
50
|
-
"pretest:types": "pnpm dev:prepare",
|
|
51
|
-
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
52
|
-
},
|
|
53
|
-
"dependencies": {
|
|
45
|
+
"catalog": {
|
|
54
46
|
"@casl/ability": "^6.8.1",
|
|
55
47
|
"@casl/vue": "^2.2.6",
|
|
56
48
|
"@nuxt/kit": "^4.4.8",
|
|
49
|
+
"better-auth": "1.6.16",
|
|
57
50
|
"defu": "^6.1.4",
|
|
58
51
|
"h3": "^1.14.0",
|
|
52
|
+
"jose": "^6.2.12",
|
|
59
53
|
"ofetch": "^1.4.1",
|
|
60
54
|
"ufo": "^1.5.4",
|
|
61
55
|
"zod": "^4.3.6"
|
|
62
|
-
},
|
|
63
|
-
"devDependencies": {
|
|
64
|
-
"@antfu/eslint-config": "catalog:",
|
|
65
|
-
"@nuxt/devtools": "catalog:",
|
|
66
|
-
"@nuxt/eslint-config": "catalog:",
|
|
67
|
-
"@nuxt/module-builder": "catalog:",
|
|
68
|
-
"@nuxt/schema": "catalog:",
|
|
69
|
-
"@nuxt/test-utils": "catalog:",
|
|
70
|
-
"@types/node": "catalog:",
|
|
71
|
-
"changelogen": "catalog:",
|
|
72
|
-
"eslint": "catalog:",
|
|
73
|
-
"nitropack": "catalog:",
|
|
74
|
-
"nuxt": "catalog:",
|
|
75
|
-
"typescript": "catalog:",
|
|
76
|
-
"unbuild": "catalog:",
|
|
77
|
-
"vitest": "catalog:",
|
|
78
|
-
"vue": "catalog:",
|
|
79
|
-
"vue-tsc": "catalog:"
|
|
80
56
|
}
|
|
81
|
-
}
|
|
57
|
+
}
|