@thecodeorigin/auth 0.0.1
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 +84 -0
- package/dist/contract/index.d.mts +117 -0
- package/dist/contract/index.d.ts +117 -0
- package/dist/contract/index.mjs +52 -0
- package/dist/module.d.mts +23 -0
- package/dist/module.d.ts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +51 -0
- package/dist/runtime/app/composables/useAuth.d.ts +106 -0
- package/dist/runtime/app/composables/useAuth.js +60 -0
- package/dist/runtime/app/composables/useCasl.d.ts +8 -0
- package/dist/runtime/app/composables/useCasl.js +9 -0
- package/dist/runtime/app/middleware/auth.global.d.ts +2 -0
- package/dist/runtime/app/middleware/auth.global.js +11 -0
- package/dist/runtime/app/middleware/casl.global.d.ts +2 -0
- package/dist/runtime/app/middleware/casl.global.js +17 -0
- package/dist/runtime/app/plugins/0.session.d.ts +2 -0
- package/dist/runtime/app/plugins/0.session.js +11 -0
- package/dist/runtime/app/plugins/ability.d.ts +7 -0
- package/dist/runtime/app/plugins/ability.js +23 -0
- package/dist/runtime/server/api/_auth/impersonatable-users.get.d.ts +2 -0
- package/dist/runtime/server/api/_auth/impersonatable-users.get.js +9 -0
- package/dist/runtime/server/api/_auth/impersonate.post.d.ts +36 -0
- package/dist/runtime/server/api/_auth/impersonate.post.js +36 -0
- package/dist/runtime/server/api/_auth/organizations/switch.post.d.ts +36 -0
- package/dist/runtime/server/api/_auth/organizations/switch.post.js +16 -0
- package/dist/runtime/server/api/_auth/organizations.get.d.ts +8 -0
- package/dist/runtime/server/api/_auth/organizations.get.js +8 -0
- package/dist/runtime/server/api/_auth/session.get.d.ts +36 -0
- package/dist/runtime/server/api/_auth/session.get.js +6 -0
- package/dist/runtime/server/api/_auth/stop-impersonating.post.d.ts +36 -0
- package/dist/runtime/server/api/_auth/stop-impersonating.post.js +21 -0
- package/dist/runtime/server/index.d.ts +2 -0
- package/dist/runtime/server/index.js +1 -0
- package/dist/runtime/server/routes/callback.get.d.ts +2 -0
- package/dist/runtime/server/routes/callback.get.js +61 -0
- package/dist/runtime/server/routes/sign-in.get.d.ts +2 -0
- package/dist/runtime/server/routes/sign-in.get.js +24 -0
- package/dist/runtime/server/routes/sign-out.get.d.ts +2 -0
- package/dist/runtime/server/routes/sign-out.get.js +19 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/server/utils/idp.d.ts +11 -0
- package/dist/runtime/server/utils/idp.js +58 -0
- package/dist/runtime/server/utils/oidc.d.ts +29 -0
- package/dist/runtime/server/utils/oidc.js +62 -0
- package/dist/runtime/server/utils/session.d.ts +74 -0
- package/dist/runtime/server/utils/session.js +79 -0
- package/dist/runtime/types.d.ts +17 -0
- package/dist/types.d.mts +3 -0
- package/package.json +77 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineNuxtRouteMiddleware, navigateTo, useNuxtApp } from "#app";
|
|
2
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
3
|
+
const required = to.meta.can ?? [];
|
|
4
|
+
if (!required.length)
|
|
5
|
+
return;
|
|
6
|
+
const { $ability } = useNuxtApp();
|
|
7
|
+
const ok = required.every((r) => {
|
|
8
|
+
const idx = r.indexOf(":");
|
|
9
|
+
if (idx === -1)
|
|
10
|
+
return false;
|
|
11
|
+
const subject = r.slice(0, idx);
|
|
12
|
+
const action = r.slice(idx + 1);
|
|
13
|
+
return $ability.can(action, subject);
|
|
14
|
+
});
|
|
15
|
+
if (!ok)
|
|
16
|
+
return navigateTo("/403");
|
|
17
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRequestFetch, useState } from "#app";
|
|
2
|
+
export default defineNuxtPlugin(async () => {
|
|
3
|
+
const session = useState("tco-auth-session", () => null);
|
|
4
|
+
if (session.value === null) {
|
|
5
|
+
try {
|
|
6
|
+
session.value = await useRequestFetch()("/api/_auth/session");
|
|
7
|
+
} catch {
|
|
8
|
+
session.value = null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MongoAbility } from '@casl/ability';
|
|
2
|
+
declare const _default: import("#app").Plugin<{
|
|
3
|
+
ability: MongoAbility<import("@casl/ability").AbilityTuple, import("@casl/ability").MongoQuery>;
|
|
4
|
+
}> & import("#app").ObjectPlugin<{
|
|
5
|
+
ability: MongoAbility<import("@casl/ability").AbilityTuple, import("@casl/ability").MongoQuery>;
|
|
6
|
+
}>;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createMongoAbility } from "@casl/ability";
|
|
2
|
+
import { abilitiesPlugin } from "@casl/vue";
|
|
3
|
+
import { watch } from "vue";
|
|
4
|
+
import { defineNuxtPlugin, useState } from "#app";
|
|
5
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
|
+
const session = useState("tco-auth-session", () => null);
|
|
7
|
+
const ability = createMongoAbility([]);
|
|
8
|
+
nuxtApp.vueApp.use(abilitiesPlugin, ability, { useGlobalProperties: true });
|
|
9
|
+
function rules() {
|
|
10
|
+
const s = session.value;
|
|
11
|
+
if (!s)
|
|
12
|
+
return [];
|
|
13
|
+
if (s.systemRole === "admin")
|
|
14
|
+
return [{ action: "manage", subject: "all" }];
|
|
15
|
+
return (s.abilities ?? []).map((a) => ({
|
|
16
|
+
action: a.action,
|
|
17
|
+
subject: a.subject,
|
|
18
|
+
...a.conditions ? { conditions: a.conditions } : {}
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
watch(session, () => ability.update(rules()), { immediate: true, deep: true });
|
|
22
|
+
return { provide: { ability } };
|
|
23
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getQuery } from "h3";
|
|
2
|
+
import { idpFetch } from "../../utils/idp.js";
|
|
3
|
+
import { readSessionRecord } from "../../utils/session.js";
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const s = await readSessionRecord(event);
|
|
6
|
+
if (!s)
|
|
7
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
8
|
+
return idpFetch(event, s.id, s.rec, "/api/auth/rp/impersonatable-users", { query: getQuery(event) });
|
|
9
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
2
|
+
user: {
|
|
3
|
+
sub: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
picture: string | null;
|
|
7
|
+
};
|
|
8
|
+
abilities: {
|
|
9
|
+
action: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
conditions?: Record<string, string> | undefined;
|
|
12
|
+
}[];
|
|
13
|
+
systemRole: string | null;
|
|
14
|
+
organizations: {
|
|
15
|
+
id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
role: string;
|
|
19
|
+
personal: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
activeOrg: string | null;
|
|
22
|
+
entitlement: {
|
|
23
|
+
product: string;
|
|
24
|
+
plan: string;
|
|
25
|
+
status: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
currentPeriodEnd: number | null;
|
|
28
|
+
} | null;
|
|
29
|
+
impersonator: {
|
|
30
|
+
sub: string;
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
picture: string | null;
|
|
34
|
+
} | null;
|
|
35
|
+
}>>;
|
|
36
|
+
export default _default;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { idpFetch } from "../../utils/idp.js";
|
|
4
|
+
import { newSessionId, readSessionRecord, toPublicSession, writeSessionRecord } from "../../utils/session.js";
|
|
5
|
+
const bodySchema = z.object({ userId: z.string() });
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const s = await readSessionRecord(event);
|
|
8
|
+
if (!s)
|
|
9
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
10
|
+
if (s.rec.isImpersonation)
|
|
11
|
+
throw createError({ statusCode: 400, statusMessage: "Already impersonating" });
|
|
12
|
+
const { userId } = await readValidatedBody(event, bodySchema.parse);
|
|
13
|
+
const res = await idpFetch(event, s.id, s.rec, "/api/auth/rp/impersonate", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: { userId }
|
|
16
|
+
});
|
|
17
|
+
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
|
+
accessExpiresAt: res.expiresAt,
|
|
30
|
+
isImpersonation: true,
|
|
31
|
+
impersonator: { sub: s.rec.user.sub, email: s.rec.user.email, name: s.rec.user.name, picture: s.rec.user.picture },
|
|
32
|
+
backupId
|
|
33
|
+
};
|
|
34
|
+
await writeSessionRecord(s.id, s.rec);
|
|
35
|
+
return toPublicSession(s.rec);
|
|
36
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
2
|
+
user: {
|
|
3
|
+
sub: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
picture: string | null;
|
|
7
|
+
};
|
|
8
|
+
abilities: {
|
|
9
|
+
action: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
conditions?: Record<string, string> | undefined;
|
|
12
|
+
}[];
|
|
13
|
+
systemRole: string | null;
|
|
14
|
+
organizations: {
|
|
15
|
+
id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
role: string;
|
|
19
|
+
personal: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
activeOrg: string | null;
|
|
22
|
+
entitlement: {
|
|
23
|
+
product: string;
|
|
24
|
+
plan: string;
|
|
25
|
+
status: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
currentPeriodEnd: number | null;
|
|
28
|
+
} | null;
|
|
29
|
+
impersonator: {
|
|
30
|
+
sub: string;
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
picture: string | null;
|
|
34
|
+
} | null;
|
|
35
|
+
}>>;
|
|
36
|
+
export default _default;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createError, defineEventHandler, readValidatedBody } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readSessionRecord, toPublicSession, writeSessionRecord } from "../../../utils/session.js";
|
|
4
|
+
const bodySchema = z.object({ organizationId: z.string() });
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const s = await readSessionRecord(event);
|
|
7
|
+
if (!s)
|
|
8
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
9
|
+
const { organizationId } = await readValidatedBody(event, bodySchema.parse);
|
|
10
|
+
const org = s.rec.organizations.find((o) => o.id === organizationId);
|
|
11
|
+
if (!org)
|
|
12
|
+
throw createError({ statusCode: 403, statusMessage: "Not a member of that organization" });
|
|
13
|
+
s.rec.activeOrg = org.id;
|
|
14
|
+
await writeSessionRecord(s.id, s.rec);
|
|
15
|
+
return toPublicSession(s.rec);
|
|
16
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createError, defineEventHandler } from "h3";
|
|
2
|
+
import { readSessionRecord } from "../../utils/session.js";
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const s = await readSessionRecord(event);
|
|
5
|
+
if (!s)
|
|
6
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
7
|
+
return s.rec.organizations;
|
|
8
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
2
|
+
user: {
|
|
3
|
+
sub: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
picture: string | null;
|
|
7
|
+
};
|
|
8
|
+
abilities: {
|
|
9
|
+
action: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
conditions?: Record<string, string> | undefined;
|
|
12
|
+
}[];
|
|
13
|
+
systemRole: string | null;
|
|
14
|
+
organizations: {
|
|
15
|
+
id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
role: string;
|
|
19
|
+
personal: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
activeOrg: string | null;
|
|
22
|
+
entitlement: {
|
|
23
|
+
product: string;
|
|
24
|
+
plan: string;
|
|
25
|
+
status: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
currentPeriodEnd: number | null;
|
|
28
|
+
} | null;
|
|
29
|
+
impersonator: {
|
|
30
|
+
sub: string;
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
picture: string | null;
|
|
34
|
+
} | null;
|
|
35
|
+
} | null>>;
|
|
36
|
+
export default _default;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
|
|
2
|
+
user: {
|
|
3
|
+
sub: string;
|
|
4
|
+
email: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
picture: string | null;
|
|
7
|
+
};
|
|
8
|
+
abilities: {
|
|
9
|
+
action: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
conditions?: Record<string, string> | undefined;
|
|
12
|
+
}[];
|
|
13
|
+
systemRole: string | null;
|
|
14
|
+
organizations: {
|
|
15
|
+
id: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
role: string;
|
|
19
|
+
personal: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
activeOrg: string | null;
|
|
22
|
+
entitlement: {
|
|
23
|
+
product: string;
|
|
24
|
+
plan: string;
|
|
25
|
+
status: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
currentPeriodEnd: number | null;
|
|
28
|
+
} | null;
|
|
29
|
+
impersonator: {
|
|
30
|
+
sub: string;
|
|
31
|
+
email: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
picture: string | null;
|
|
34
|
+
} | null;
|
|
35
|
+
}>>;
|
|
36
|
+
export default _default;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createError, defineEventHandler } from "h3";
|
|
2
|
+
import { useStorage } from "nitropack/runtime";
|
|
3
|
+
import { idpFetch } from "../../utils/idp.js";
|
|
4
|
+
import { resolveAuthConfig } from "../../utils/oidc.js";
|
|
5
|
+
import { readSessionRecord, readSessionRecordById, toPublicSession, writeSessionRecord } from "../../utils/session.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const s = await readSessionRecord(event);
|
|
8
|
+
if (!s || !s.rec.isImpersonation)
|
|
9
|
+
throw createError({ statusCode: 400, statusMessage: "Not impersonating" });
|
|
10
|
+
try {
|
|
11
|
+
await idpFetch(event, s.id, s.rec, "/api/auth/rp/stop-impersonating", { method: "POST" });
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
const backup = s.rec.backupId ? await readSessionRecordById(s.rec.backupId) : null;
|
|
15
|
+
if (!backup)
|
|
16
|
+
throw createError({ statusCode: 500, statusMessage: "Original session missing \u2014 sign in again" });
|
|
17
|
+
await writeSessionRecord(s.id, backup);
|
|
18
|
+
const cfg = resolveAuthConfig();
|
|
19
|
+
await useStorage(cfg.storageBase).removeItem(`session:${s.rec.backupId}`);
|
|
20
|
+
return toPublicSession(backup);
|
|
21
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getServerAuthSession } from "./utils/session.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineEventHandler, deleteCookie, getCookie, getQuery, sendRedirect } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { UserinfoClaimsSchema } from "../../../contract";
|
|
4
|
+
import { callbackRedirectUri, exchangeCode, fetchUserinfo, resolveAuthConfig, safePath } from "../utils/oidc.js";
|
|
5
|
+
import { newSessionId, setSessionCookie, writeSessionRecord } from "../utils/session.js";
|
|
6
|
+
const RawUserinfoSchema = UserinfoClaimsSchema.extend({
|
|
7
|
+
sub: z.string(),
|
|
8
|
+
email: z.string().optional(),
|
|
9
|
+
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
|
10
|
+
name: z.string().optional(),
|
|
11
|
+
picture: z.string().optional()
|
|
12
|
+
});
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
const cfg = resolveAuthConfig();
|
|
15
|
+
const q = getQuery(event);
|
|
16
|
+
const fail = (e) => sendRedirect(event, `${cfg.routes.error}?error=${encodeURIComponent(e)}`);
|
|
17
|
+
if (q.error)
|
|
18
|
+
return fail(String(q.error));
|
|
19
|
+
const state = getCookie(event, "tco_state");
|
|
20
|
+
const verifier = getCookie(event, "tco_verifier");
|
|
21
|
+
const redirectTo = safePath(getCookie(event, "tco_redirect"), cfg.routes.home);
|
|
22
|
+
for (const c of ["tco_state", "tco_verifier", "tco_redirect"])
|
|
23
|
+
deleteCookie(event, c, { path: cfg.routes.callback });
|
|
24
|
+
if (!q.code || !q.state || !state || q.state !== state || !verifier)
|
|
25
|
+
return fail("invalid_state");
|
|
26
|
+
let tokens;
|
|
27
|
+
let userinfoRaw;
|
|
28
|
+
try {
|
|
29
|
+
tokens = await exchangeCode(cfg, String(q.code), verifier, callbackRedirectUri(event, cfg));
|
|
30
|
+
userinfoRaw = await fetchUserinfo(cfg, tokens.access_token);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
33
|
+
console.error("[auth:callback] exchange failed:", detail);
|
|
34
|
+
return fail("token_exchange_failed");
|
|
35
|
+
}
|
|
36
|
+
const parsed = RawUserinfoSchema.safeParse(userinfoRaw);
|
|
37
|
+
if (!parsed.success)
|
|
38
|
+
return fail("userinfo_invalid");
|
|
39
|
+
const u = parsed.data;
|
|
40
|
+
const verified = u.email_verified === true || u.email_verified === "true";
|
|
41
|
+
if (!u.sub || !u.email || !verified)
|
|
42
|
+
return fail("email_unverified");
|
|
43
|
+
const id = newSessionId();
|
|
44
|
+
await writeSessionRecord(id, {
|
|
45
|
+
sub: u.sub,
|
|
46
|
+
user: { sub: u.sub, email: u.email, name: u.name ?? null, picture: u.picture ?? null },
|
|
47
|
+
abilities: u.abilities,
|
|
48
|
+
systemRole: u.role,
|
|
49
|
+
organizations: u.organizations,
|
|
50
|
+
activeOrg: u.org,
|
|
51
|
+
entitlement: u.entitlement,
|
|
52
|
+
accessToken: tokens.access_token,
|
|
53
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
54
|
+
accessExpiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1e3,
|
|
55
|
+
isImpersonation: false,
|
|
56
|
+
impersonator: null,
|
|
57
|
+
backupId: null
|
|
58
|
+
});
|
|
59
|
+
await setSessionCookie(event, id);
|
|
60
|
+
return sendRedirect(event, redirectTo);
|
|
61
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getQuery, sendRedirect, setCookie } from "h3";
|
|
2
|
+
import { withQuery } from "ufo";
|
|
3
|
+
import { callbackRedirectUri, pkceChallenge, randomString, resolveAuthConfig, safePath } from "../utils/oidc.js";
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const cfg = resolveAuthConfig();
|
|
6
|
+
if (!cfg.issuer || !cfg.clientId || !cfg.clientSecret)
|
|
7
|
+
throw createError({ statusCode: 503, statusMessage: "Auth not configured" });
|
|
8
|
+
const state = randomString(32);
|
|
9
|
+
const verifier = randomString(64);
|
|
10
|
+
const redirectTo = safePath(getQuery(event).redirect, cfg.routes.home);
|
|
11
|
+
const opts = { httpOnly: true, secure: !import.meta.dev, sameSite: "lax", maxAge: 600, path: cfg.routes.callback };
|
|
12
|
+
setCookie(event, "tco_state", state, opts);
|
|
13
|
+
setCookie(event, "tco_verifier", verifier, opts);
|
|
14
|
+
setCookie(event, "tco_redirect", redirectTo, opts);
|
|
15
|
+
return sendRedirect(event, withQuery(`${cfg.issuer}/oauth2/authorize`, {
|
|
16
|
+
client_id: cfg.clientId,
|
|
17
|
+
redirect_uri: callbackRedirectUri(event, cfg),
|
|
18
|
+
response_type: "code",
|
|
19
|
+
scope: cfg.scopes.join(" "),
|
|
20
|
+
state,
|
|
21
|
+
code_challenge: await pkceChallenge(verifier),
|
|
22
|
+
code_challenge_method: "S256"
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineEventHandler, sendRedirect } from "h3";
|
|
2
|
+
import { $fetch } from "ofetch";
|
|
3
|
+
import { resolveAuthConfig } from "../utils/oidc.js";
|
|
4
|
+
import { destroySession } from "../utils/session.js";
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const cfg = resolveAuthConfig();
|
|
7
|
+
const rec = await destroySession(event);
|
|
8
|
+
if (rec?.accessToken) {
|
|
9
|
+
try {
|
|
10
|
+
await $fetch(`${cfg.issuer}/oauth2/endsession`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
body: new URLSearchParams({ token: rec.refreshToken ?? rec.accessToken }).toString(),
|
|
13
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
14
|
+
});
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return sendRedirect(event, cfg.routes.home);
|
|
19
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { SessionRecord } from './session.js';
|
|
3
|
+
/**
|
|
4
|
+
* Call an IdP path with the session's bearer token. Refreshes once on expiry/401.
|
|
5
|
+
* Persists the rotated record. Returns parsed JSON.
|
|
6
|
+
*/
|
|
7
|
+
export declare function idpFetch<T>(event: H3Event, id: string, rec: SessionRecord, path: string, opts?: {
|
|
8
|
+
method?: string;
|
|
9
|
+
body?: Record<string, unknown>;
|
|
10
|
+
query?: Record<string, string | number>;
|
|
11
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { $fetch } from "ofetch";
|
|
3
|
+
import { resolveAuthConfig } from "./oidc.js";
|
|
4
|
+
import { readSessionRecordById, writeSessionRecord } from "./session.js";
|
|
5
|
+
const SKEW_MS = 6e4;
|
|
6
|
+
async function refresh(cfg, rec) {
|
|
7
|
+
if (rec.isImpersonation || !rec.refreshToken)
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
const t = await $fetch(
|
|
11
|
+
`${cfg.issuer}/oauth2/token`,
|
|
12
|
+
{
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
16
|
+
"Authorization": `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`
|
|
17
|
+
},
|
|
18
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: rec.refreshToken }).toString()
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
rec.accessToken = t.access_token;
|
|
22
|
+
rec.refreshToken = t.refresh_token ?? rec.refreshToken;
|
|
23
|
+
rec.accessExpiresAt = Date.now() + (t.expires_in ?? 3600) * 1e3;
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function idpFetch(event, id, rec, path, opts = {}) {
|
|
30
|
+
const cfg = resolveAuthConfig();
|
|
31
|
+
if (!rec.isImpersonation && Date.now() > rec.accessExpiresAt - SKEW_MS) {
|
|
32
|
+
if (await refresh(cfg, rec))
|
|
33
|
+
await writeSessionRecord(id, rec);
|
|
34
|
+
}
|
|
35
|
+
const call = () => $fetch(`${cfg.issuer}${path}`, {
|
|
36
|
+
method: opts.method,
|
|
37
|
+
body: opts.body,
|
|
38
|
+
query: opts.query,
|
|
39
|
+
headers: { Authorization: `Bearer ${rec.accessToken}` }
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
return await call();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
const status = e?.response?.status;
|
|
45
|
+
if (status !== 401)
|
|
46
|
+
throw e;
|
|
47
|
+
const fresh = await readSessionRecordById(id);
|
|
48
|
+
if (fresh && fresh.accessToken !== rec.accessToken) {
|
|
49
|
+
Object.assign(rec, fresh);
|
|
50
|
+
return call();
|
|
51
|
+
}
|
|
52
|
+
if (await refresh(cfg, rec)) {
|
|
53
|
+
await writeSessionRecord(id, rec);
|
|
54
|
+
return call();
|
|
55
|
+
}
|
|
56
|
+
throw createError({ statusCode: 401, statusMessage: "Session expired" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
export interface ResolvedAuthConfig {
|
|
3
|
+
issuer: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
routes: {
|
|
8
|
+
signIn: string;
|
|
9
|
+
callback: string;
|
|
10
|
+
signOut: string;
|
|
11
|
+
home: string;
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
14
|
+
cookieName: string;
|
|
15
|
+
storageBase: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function resolveAuthConfig(): ResolvedAuthConfig;
|
|
18
|
+
export declare function randomString(len?: number): string;
|
|
19
|
+
export declare function pkceChallenge(verifier: string): Promise<string>;
|
|
20
|
+
export declare function callbackRedirectUri(event: H3Event, cfg: ResolvedAuthConfig): string;
|
|
21
|
+
export declare function exchangeCode(cfg: ResolvedAuthConfig, code: string, verifier: string, redirectUri: string): Promise<{
|
|
22
|
+
access_token: string;
|
|
23
|
+
refresh_token?: string;
|
|
24
|
+
expires_in?: number;
|
|
25
|
+
id_token?: string;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function fetchUserinfo(cfg: ResolvedAuthConfig, accessToken: string): Promise<any>;
|
|
28
|
+
/** Same-origin, path-only redirect target (prevents open redirect). */
|
|
29
|
+
export declare function safePath(target: string | undefined, fallback: string): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getRequestHost, getRequestProtocol } from "h3";
|
|
2
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
+
import { $fetch } from "ofetch";
|
|
4
|
+
export function resolveAuthConfig() {
|
|
5
|
+
const rc = useRuntimeConfig();
|
|
6
|
+
const pub = rc.public.auth ?? {};
|
|
7
|
+
const priv = rc.auth ?? {};
|
|
8
|
+
const domain = process.env.NUXT_THECODEORIGIN_DOMAIN || pub.domain || "";
|
|
9
|
+
return {
|
|
10
|
+
issuer: process.env.NUXT_THECODEORIGIN_ISSUER || pub.issuer || (domain ? `https://${domain}/api/auth` : ""),
|
|
11
|
+
clientId: process.env.NUXT_THECODEORIGIN_CLIENT_ID || pub.clientId || "",
|
|
12
|
+
clientSecret: process.env.NUXT_THECODEORIGIN_CLIENT_SECRET || priv.clientSecret || "",
|
|
13
|
+
scopes: pub.scopes ?? ["openid", "profile", "email"],
|
|
14
|
+
routes: pub.routes,
|
|
15
|
+
cookieName: priv.sessionCookieName || "tco_auth",
|
|
16
|
+
storageBase: priv.sessionStorageBase || "auth"
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function b64url(buf) {
|
|
20
|
+
return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
22
|
+
export function randomString(len = 64) {
|
|
23
|
+
const a = new Uint8Array(len);
|
|
24
|
+
crypto.getRandomValues(a);
|
|
25
|
+
return b64url(a.buffer).slice(0, len);
|
|
26
|
+
}
|
|
27
|
+
export async function pkceChallenge(verifier) {
|
|
28
|
+
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
29
|
+
}
|
|
30
|
+
export function callbackRedirectUri(event, cfg) {
|
|
31
|
+
const proto = getRequestProtocol(event);
|
|
32
|
+
const host = getRequestHost(event);
|
|
33
|
+
return `${proto}://${host}${cfg.routes.callback}`;
|
|
34
|
+
}
|
|
35
|
+
export async function exchangeCode(cfg, code, verifier, redirectUri) {
|
|
36
|
+
return $fetch(
|
|
37
|
+
`${cfg.issuer}/oauth2/token`,
|
|
38
|
+
{
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
42
|
+
"Authorization": `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`
|
|
43
|
+
},
|
|
44
|
+
body: new URLSearchParams({
|
|
45
|
+
grant_type: "authorization_code",
|
|
46
|
+
code,
|
|
47
|
+
redirect_uri: redirectUri,
|
|
48
|
+
code_verifier: verifier
|
|
49
|
+
}).toString()
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
export async function fetchUserinfo(cfg, accessToken) {
|
|
54
|
+
return $fetch(`${cfg.issuer}/oauth2/userinfo`, {
|
|
55
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function safePath(target, fallback) {
|
|
59
|
+
if (!target || !target.startsWith("/") || target.startsWith("//"))
|
|
60
|
+
return fallback;
|
|
61
|
+
return target;
|
|
62
|
+
}
|