nuxt-huia-oidc 1.0.0-alpha.5
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 +69 -0
- package/dist/module.d.mts +62 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +115 -0
- package/dist/runtime/app/composables/useHuia.d.ts +41 -0
- package/dist/runtime/app/composables/useHuia.js +25 -0
- package/dist/runtime/app/composables/useUserSession.d.ts +43 -0
- package/dist/runtime/app/composables/useUserSession.js +36 -0
- package/dist/runtime/app/middleware/auth.d.ts +3 -0
- package/dist/runtime/app/middleware/auth.js +14 -0
- package/dist/runtime/app/plugins/session.server.d.ts +8 -0
- package/dist/runtime/app/plugins/session.server.js +8 -0
- package/dist/runtime/app/utils/route-match.d.ts +8 -0
- package/dist/runtime/app/utils/route-match.js +9 -0
- package/dist/runtime/server/middleware/session.context.d.ts +7 -0
- package/dist/runtime/server/middleware/session.context.js +6 -0
- package/dist/runtime/server/plugins/oidc.discovery.d.ts +2 -0
- package/dist/runtime/server/plugins/oidc.discovery.js +5 -0
- package/dist/runtime/server/routes/auth/oidc/callback.get.d.ts +2 -0
- package/dist/runtime/server/routes/auth/oidc/callback.get.js +16 -0
- package/dist/runtime/server/routes/auth/oidc/login.get.d.ts +2 -0
- package/dist/runtime/server/routes/auth/oidc/login.get.js +21 -0
- package/dist/runtime/server/routes/auth/oidc/logout.get.d.ts +2 -0
- package/dist/runtime/server/routes/auth/oidc/logout.get.js +22 -0
- package/dist/runtime/server/routes/auth/session.get.d.ts +2 -0
- package/dist/runtime/server/routes/auth/session.get.js +6 -0
- package/dist/runtime/server/utils/config.d.ts +58 -0
- package/dist/runtime/server/utils/config.js +59 -0
- package/dist/runtime/server/utils/cookie.d.ts +9 -0
- package/dist/runtime/server/utils/cookie.js +90 -0
- package/dist/runtime/server/utils/internal-types.d.ts +79 -0
- package/dist/runtime/server/utils/internal-types.js +0 -0
- package/dist/runtime/server/utils/oidc.d.ts +24 -0
- package/dist/runtime/server/utils/oidc.js +168 -0
- package/dist/runtime/server/utils/refresh.d.ts +7 -0
- package/dist/runtime/server/utils/refresh.js +108 -0
- package/dist/runtime/server/utils/session.d.ts +15 -0
- package/dist/runtime/server/utils/session.js +114 -0
- package/dist/runtime/server/utils/storage.d.ts +17 -0
- package/dist/runtime/server/utils/storage.js +46 -0
- package/dist/runtime/server/utils/tokens.d.ts +1 -0
- package/dist/runtime/server/utils/tokens.js +6 -0
- package/dist/runtime/types.d.ts +57 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# nuxt-huia-oidc
|
|
2
|
+
|
|
3
|
+
**Nuxt 4** authentication module for the [Huia.OpenId](https://github.com/Ayman-Elfaki/Huia)
|
|
4
|
+
identity provider. OIDC Authorization Code flow with PKCE, RFC 9126 Pushed Authorization Requests,
|
|
5
|
+
transparent server-side token refresh, and a dual-layer session that keeps **every token on the
|
|
6
|
+
server**.
|
|
7
|
+
|
|
8
|
+
Its sibling module, [`nuxt-huia-headless`](../nuxt-huia-headless), talks to `Huia.Headless` instead —
|
|
9
|
+
a JSON register/login API with no OIDC redirect — for apps that own their own login form. Use this
|
|
10
|
+
module when the app authenticates against a multi-tenant `Huia.OpenId` server via OIDC redirects.
|
|
11
|
+
|
|
12
|
+
- Built on [`openid-client`](https://github.com/panva/openid-client) v6 (ESM, Web Crypto)
|
|
13
|
+
- Requires Nuxt `>=4`, Nitro `>=2.10`, Node `>=20.11`
|
|
14
|
+
- Full spec: [`SPEC.md`](./SPEC.md) · docs: <https://github.com/Ayman-Elfaki/Huia/tree/main/docs/nuxt>
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install nuxt-huia-oidc
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// nuxt.config.ts
|
|
24
|
+
export default defineNuxtConfig({
|
|
25
|
+
modules: ['nuxt-huia-oidc'],
|
|
26
|
+
huia: {
|
|
27
|
+
baseUrl: 'https://id.example.com',
|
|
28
|
+
tenant: 'acme',
|
|
29
|
+
clientId: 'acme-web',
|
|
30
|
+
// clientSecret via NUXT_HUIA_CLIENT_SECRET
|
|
31
|
+
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
|
32
|
+
par: { enabled: true },
|
|
33
|
+
// session.password via NUXT_HUIA_SESSION_PASSWORD (>= 32 chars)
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```vue
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
const { user, loggedIn } = useUserSession()
|
|
43
|
+
const { login, logout } = useHuia()
|
|
44
|
+
</script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// server/api/me.get.ts
|
|
49
|
+
export default defineEventHandler(async (event) => {
|
|
50
|
+
const { user } = await requireUserSession(event) // 401 if anonymous
|
|
51
|
+
return { id: user.sub, name: user.name }
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The browser only ever holds a sealed, chunked `__Host-huia_sess` cookie carrying the session id and a
|
|
56
|
+
whitelisted claim subset. Access / refresh / id tokens live in Nitro Storage, keyed by that id, and
|
|
57
|
+
are refreshed transparently. See [`SPEC.md`](./SPEC.md) for the architecture, the cookie-chunking and
|
|
58
|
+
soft-lock algorithms, the security table, and the test strategy.
|
|
59
|
+
|
|
60
|
+
## Develop
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install
|
|
64
|
+
npm run dev:prepare
|
|
65
|
+
npm test # Vitest unit + integration (mocked OP)
|
|
66
|
+
npm run dev # playground on http://localhost:3000
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`.npmrc` pins `legacy-peer-deps=true` (an npm 10 arborist crash on the nuxt peer graph).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
tenant?: string;
|
|
6
|
+
issuer?: string;
|
|
7
|
+
clientId: string;
|
|
8
|
+
clientSecret?: string;
|
|
9
|
+
redirectUrl?: string;
|
|
10
|
+
scopes?: string[];
|
|
11
|
+
allowedAuthParams?: string[];
|
|
12
|
+
par?: {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
};
|
|
16
|
+
session?: {
|
|
17
|
+
name?: string;
|
|
18
|
+
password?: string;
|
|
19
|
+
maxAge?: number;
|
|
20
|
+
cookie?: {
|
|
21
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
22
|
+
secure?: boolean;
|
|
23
|
+
};
|
|
24
|
+
userClaims?: string[];
|
|
25
|
+
};
|
|
26
|
+
storage?: {
|
|
27
|
+
base?: string;
|
|
28
|
+
};
|
|
29
|
+
refresh?: {
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
earlyRefreshSeconds?: number;
|
|
32
|
+
lock?: {
|
|
33
|
+
ttlMs?: number;
|
|
34
|
+
waitMs?: number;
|
|
35
|
+
pollMs?: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
cookie?: {
|
|
39
|
+
chunkSize?: number;
|
|
40
|
+
maxChunks?: number;
|
|
41
|
+
};
|
|
42
|
+
middleware?: {
|
|
43
|
+
global?: boolean;
|
|
44
|
+
exclude?: string[];
|
|
45
|
+
};
|
|
46
|
+
hydration?: 'useState' | 'asyncData';
|
|
47
|
+
routes?: {
|
|
48
|
+
login?: string;
|
|
49
|
+
callback?: string;
|
|
50
|
+
logout?: string;
|
|
51
|
+
session?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
};
|
|
54
|
+
logout?: {
|
|
55
|
+
rpInitiated?: boolean;
|
|
56
|
+
};
|
|
57
|
+
allowInsecureTls?: boolean;
|
|
58
|
+
}
|
|
59
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
60
|
+
|
|
61
|
+
export { _default as default };
|
|
62
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerImportsDir, addImportsDir, addPlugin, addRouteMiddleware, addServerHandler, addServerPlugin } from '@nuxt/kit';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
|
|
4
|
+
const defaults = {
|
|
5
|
+
baseUrl: "",
|
|
6
|
+
tenant: "",
|
|
7
|
+
issuer: "",
|
|
8
|
+
clientId: "",
|
|
9
|
+
redirectUrl: "/auth/oidc/callback",
|
|
10
|
+
scopes: ["openid", "profile", "email", "offline_access"],
|
|
11
|
+
allowedAuthParams: ["ui_locales", "prompt", "login_hint"],
|
|
12
|
+
par: { enabled: true, required: false },
|
|
13
|
+
session: {
|
|
14
|
+
name: "__Host-huia_sess",
|
|
15
|
+
password: "",
|
|
16
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
17
|
+
cookie: { sameSite: "lax" },
|
|
18
|
+
userClaims: ["sub", "name", "email", "preferred_username", "given_name", "family_name", "roles"]
|
|
19
|
+
},
|
|
20
|
+
storage: { base: "huia-auth" },
|
|
21
|
+
refresh: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
earlyRefreshSeconds: 60,
|
|
24
|
+
lock: { ttlMs: 1e4, waitMs: 8e3, pollMs: 150 }
|
|
25
|
+
},
|
|
26
|
+
cookie: { chunkSize: 3800, maxChunks: 8 },
|
|
27
|
+
middleware: { global: false, exclude: [] },
|
|
28
|
+
hydration: "useState",
|
|
29
|
+
routes: {
|
|
30
|
+
login: "/auth/oidc/login",
|
|
31
|
+
callback: "/auth/oidc/callback",
|
|
32
|
+
logout: "/auth/oidc/logout",
|
|
33
|
+
session: "/auth/session",
|
|
34
|
+
error: "/"
|
|
35
|
+
},
|
|
36
|
+
logout: { rpInitiated: true },
|
|
37
|
+
allowInsecureTls: false
|
|
38
|
+
};
|
|
39
|
+
const module$1 = defineNuxtModule({
|
|
40
|
+
meta: {
|
|
41
|
+
name: "nuxt-huia-oidc",
|
|
42
|
+
configKey: "huia",
|
|
43
|
+
compatibility: { nuxt: ">=4.0.0" }
|
|
44
|
+
},
|
|
45
|
+
defaults,
|
|
46
|
+
setup(options, nuxt) {
|
|
47
|
+
const { resolve } = createResolver(import.meta.url);
|
|
48
|
+
const opts = defu(options, defaults);
|
|
49
|
+
if (!opts.session.password && !process.env.NUXT_HUIA_SESSION_PASSWORD) {
|
|
50
|
+
console.warn("[huia-auth] no session password set \u2014 set NUXT_HUIA_SESSION_PASSWORD (>= 32 chars)");
|
|
51
|
+
}
|
|
52
|
+
nuxt.options.runtimeConfig.huia = defu(
|
|
53
|
+
nuxt.options.runtimeConfig.huia,
|
|
54
|
+
{
|
|
55
|
+
clientId: opts.clientId,
|
|
56
|
+
clientSecret: opts.clientSecret ?? "",
|
|
57
|
+
issuer: opts.issuer ?? "",
|
|
58
|
+
baseUrl: opts.baseUrl ?? "",
|
|
59
|
+
tenant: opts.tenant ?? "",
|
|
60
|
+
redirectUrl: opts.redirectUrl,
|
|
61
|
+
scopes: opts.scopes,
|
|
62
|
+
allowedAuthParams: opts.allowedAuthParams,
|
|
63
|
+
par: opts.par,
|
|
64
|
+
session: { ...opts.session, password: opts.session.password ?? "" },
|
|
65
|
+
storage: opts.storage,
|
|
66
|
+
refresh: opts.refresh,
|
|
67
|
+
cookie: opts.cookie,
|
|
68
|
+
routes: opts.routes,
|
|
69
|
+
logout: opts.logout,
|
|
70
|
+
allowInsecureTls: opts.allowInsecureTls
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
nuxt.options.runtimeConfig.public.huia = defu(
|
|
74
|
+
nuxt.options.runtimeConfig.public.huia,
|
|
75
|
+
{
|
|
76
|
+
loginPath: opts.routes.login,
|
|
77
|
+
logoutPath: opts.routes.logout,
|
|
78
|
+
sessionPath: opts.routes.session,
|
|
79
|
+
// The module's own auth routes are never protected by its own middleware, regardless of
|
|
80
|
+
// `middleware.exclude` — applying "must be logged in" to the routes that exist precisely for
|
|
81
|
+
// the logged-out flow is never correct, and was the mechanism behind the infinite-redirect
|
|
82
|
+
// risk of turning on `middleware.global` with no exclusions configured.
|
|
83
|
+
middlewareExclude: [
|
|
84
|
+
...opts.middleware.exclude,
|
|
85
|
+
opts.routes.login,
|
|
86
|
+
opts.routes.callback,
|
|
87
|
+
opts.routes.logout,
|
|
88
|
+
opts.routes.session
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
nuxt.options.alias["#huia-auth"] = resolve("runtime/types");
|
|
93
|
+
addServerImportsDir(resolve("runtime/server/utils"));
|
|
94
|
+
addImportsDir(resolve("runtime/app/composables"));
|
|
95
|
+
if (opts.hydration === "useState") {
|
|
96
|
+
addPlugin({ src: resolve("runtime/app/plugins/session.server"), mode: "server" });
|
|
97
|
+
}
|
|
98
|
+
addRouteMiddleware({
|
|
99
|
+
name: "auth",
|
|
100
|
+
path: resolve("runtime/app/middleware/auth"),
|
|
101
|
+
global: opts.middleware.global
|
|
102
|
+
});
|
|
103
|
+
addServerHandler({ route: opts.routes.login, method: "get", handler: resolve("runtime/server/routes/auth/oidc/login.get") });
|
|
104
|
+
addServerHandler({ route: opts.routes.callback, method: "get", handler: resolve("runtime/server/routes/auth/oidc/callback.get") });
|
|
105
|
+
addServerHandler({ route: opts.routes.logout, method: "get", handler: resolve("runtime/server/routes/auth/oidc/logout.get") });
|
|
106
|
+
addServerHandler({ route: opts.routes.session, method: "get", handler: resolve("runtime/server/routes/auth/session.get") });
|
|
107
|
+
addServerHandler({ middleware: true, handler: resolve("runtime/server/middleware/session.context") });
|
|
108
|
+
addServerPlugin(resolve("runtime/server/plugins/oidc.discovery"));
|
|
109
|
+
nuxt.hook("prepare:types", ({ references }) => {
|
|
110
|
+
references.push({ path: resolve("runtime/types.d.ts") });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare function useHuia(): {
|
|
2
|
+
user: import("vue").ComputedRef<import("../../types.js").UserClaims | null>;
|
|
3
|
+
loggedIn: import("vue").ComputedRef<boolean>;
|
|
4
|
+
session: Readonly<import("vue").Ref<{
|
|
5
|
+
readonly user?: {
|
|
6
|
+
readonly [x: string]: Readonly<unknown>;
|
|
7
|
+
readonly sub: string;
|
|
8
|
+
readonly name?: string | undefined;
|
|
9
|
+
readonly email?: string | undefined;
|
|
10
|
+
readonly preferred_username?: string | undefined;
|
|
11
|
+
readonly given_name?: string | undefined;
|
|
12
|
+
readonly family_name?: string | undefined;
|
|
13
|
+
readonly roles?: readonly string[] | undefined;
|
|
14
|
+
} | undefined;
|
|
15
|
+
readonly loggedIn?: boolean | undefined;
|
|
16
|
+
readonly expiresAt?: number | undefined;
|
|
17
|
+
}, {
|
|
18
|
+
readonly user?: {
|
|
19
|
+
readonly [x: string]: Readonly<unknown>;
|
|
20
|
+
readonly sub: string;
|
|
21
|
+
readonly name?: string | undefined;
|
|
22
|
+
readonly email?: string | undefined;
|
|
23
|
+
readonly preferred_username?: string | undefined;
|
|
24
|
+
readonly given_name?: string | undefined;
|
|
25
|
+
readonly family_name?: string | undefined;
|
|
26
|
+
readonly roles?: readonly string[] | undefined;
|
|
27
|
+
} | undefined;
|
|
28
|
+
readonly loggedIn?: boolean | undefined;
|
|
29
|
+
readonly expiresAt?: number | undefined;
|
|
30
|
+
}>>;
|
|
31
|
+
hasRole: (role: string) => boolean;
|
|
32
|
+
hasAnyRole: (...roles: string[]) => boolean;
|
|
33
|
+
login(opts?: {
|
|
34
|
+
returnTo?: string;
|
|
35
|
+
locale?: string;
|
|
36
|
+
prompt?: string;
|
|
37
|
+
}): string | false | void | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | Promise<false | void | import("vue-router").NavigationFailure>;
|
|
38
|
+
logout(opts?: {
|
|
39
|
+
returnTo?: string;
|
|
40
|
+
}): string | false | void | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric | Promise<false | void | import("vue-router").NavigationFailure>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { navigateTo, useRoute, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { useUserSession } from "./useUserSession.js";
|
|
3
|
+
export function useHuia() {
|
|
4
|
+
const { user, loggedIn, session, hasRole, hasAnyRole } = useUserSession();
|
|
5
|
+
const paths = useRuntimeConfig().public.huia;
|
|
6
|
+
return {
|
|
7
|
+
user,
|
|
8
|
+
loggedIn,
|
|
9
|
+
session,
|
|
10
|
+
hasRole,
|
|
11
|
+
hasAnyRole,
|
|
12
|
+
login(opts = {}) {
|
|
13
|
+
const query = { returnTo: opts.returnTo ?? useRoute().fullPath };
|
|
14
|
+
if (opts.locale) query.ui_locales = opts.locale;
|
|
15
|
+
if (opts.prompt) query.prompt = opts.prompt;
|
|
16
|
+
return navigateTo({ path: paths.loginPath, query }, { external: true });
|
|
17
|
+
},
|
|
18
|
+
logout(opts = {}) {
|
|
19
|
+
return navigateTo(
|
|
20
|
+
{ path: paths.logoutPath, query: { returnTo: opts.returnTo ?? "/" } },
|
|
21
|
+
{ external: true }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { UserClaims } from '../../types.js';
|
|
2
|
+
export declare function useUserSession(): {
|
|
3
|
+
session: Readonly<import("vue").Ref<{
|
|
4
|
+
readonly user?: {
|
|
5
|
+
readonly [x: string]: Readonly<unknown>;
|
|
6
|
+
readonly sub: string;
|
|
7
|
+
readonly name?: string | undefined;
|
|
8
|
+
readonly email?: string | undefined;
|
|
9
|
+
readonly preferred_username?: string | undefined;
|
|
10
|
+
readonly given_name?: string | undefined;
|
|
11
|
+
readonly family_name?: string | undefined;
|
|
12
|
+
readonly roles?: readonly string[] | undefined;
|
|
13
|
+
} | undefined;
|
|
14
|
+
readonly loggedIn?: boolean | undefined;
|
|
15
|
+
readonly expiresAt?: number | undefined;
|
|
16
|
+
}, {
|
|
17
|
+
readonly user?: {
|
|
18
|
+
readonly [x: string]: Readonly<unknown>;
|
|
19
|
+
readonly sub: string;
|
|
20
|
+
readonly name?: string | undefined;
|
|
21
|
+
readonly email?: string | undefined;
|
|
22
|
+
readonly preferred_username?: string | undefined;
|
|
23
|
+
readonly given_name?: string | undefined;
|
|
24
|
+
readonly family_name?: string | undefined;
|
|
25
|
+
readonly roles?: readonly string[] | undefined;
|
|
26
|
+
} | undefined;
|
|
27
|
+
readonly loggedIn?: boolean | undefined;
|
|
28
|
+
readonly expiresAt?: number | undefined;
|
|
29
|
+
}>>;
|
|
30
|
+
user: import("vue").ComputedRef<UserClaims | null>;
|
|
31
|
+
loggedIn: import("vue").ComputedRef<boolean>;
|
|
32
|
+
expiresAt: import("vue").ComputedRef<number | undefined>;
|
|
33
|
+
/** Re-read the server session (after returning from login, on tab focus, …). */
|
|
34
|
+
fetch(): Promise<void>;
|
|
35
|
+
/** Full logout via the server route (clears storage + cookies, hits the OP end_session). */
|
|
36
|
+
clear(): Promise<void>;
|
|
37
|
+
/** Optimistic local reset only (UI); does not touch the server. */
|
|
38
|
+
clearLocal(): void;
|
|
39
|
+
/** Whether the signed-in user has `role` (from the `roles` claim — requires the `roles` scope). */
|
|
40
|
+
hasRole(role: string): boolean;
|
|
41
|
+
/** Whether the signed-in user has at least one of `roles`. */
|
|
42
|
+
hasAnyRole(...roles: string[]): boolean;
|
|
43
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { computed, readonly } from "vue";
|
|
2
|
+
import { useState, useRequestFetch, useRoute, navigateTo, useRuntimeConfig } from "#imports";
|
|
3
|
+
export function useUserSession() {
|
|
4
|
+
const session = useState("huia-auth:session", () => ({}));
|
|
5
|
+
const paths = useRuntimeConfig().public.huia;
|
|
6
|
+
return {
|
|
7
|
+
session: readonly(session),
|
|
8
|
+
user: computed(() => session.value.user ?? null),
|
|
9
|
+
loggedIn: computed(() => !!session.value.user),
|
|
10
|
+
expiresAt: computed(() => session.value.expiresAt),
|
|
11
|
+
/** Re-read the server session (after returning from login, on tab focus, …). */
|
|
12
|
+
async fetch() {
|
|
13
|
+
session.value = await useRequestFetch()(paths.sessionPath);
|
|
14
|
+
},
|
|
15
|
+
/** Full logout via the server route (clears storage + cookies, hits the OP end_session). */
|
|
16
|
+
async clear() {
|
|
17
|
+
await navigateTo(
|
|
18
|
+
{ path: paths.logoutPath, query: { returnTo: useRoute().fullPath } },
|
|
19
|
+
{ external: true }
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
/** Optimistic local reset only (UI); does not touch the server. */
|
|
23
|
+
clearLocal() {
|
|
24
|
+
session.value = {};
|
|
25
|
+
},
|
|
26
|
+
/** Whether the signed-in user has `role` (from the `roles` claim — requires the `roles` scope). */
|
|
27
|
+
hasRole(role) {
|
|
28
|
+
return !!session.value.user?.roles?.includes(role);
|
|
29
|
+
},
|
|
30
|
+
/** Whether the signed-in user has at least one of `roles`. */
|
|
31
|
+
hasAnyRole(...roles) {
|
|
32
|
+
const userRoles = session.value.user?.roles;
|
|
33
|
+
return !!userRoles && roles.some((role) => userRoles.includes(role));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineNuxtRouteMiddleware, navigateTo, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { useUserSession } from "../composables/useUserSession.js";
|
|
3
|
+
import { isExcluded } from "../utils/route-match.js";
|
|
4
|
+
const middleware = (to) => {
|
|
5
|
+
const { loginPath, middlewareExclude } = useRuntimeConfig().public.huia;
|
|
6
|
+
if (isExcluded(to.path, middlewareExclude)) return;
|
|
7
|
+
const { loggedIn } = useUserSession();
|
|
8
|
+
if (loggedIn.value) return;
|
|
9
|
+
return navigateTo(
|
|
10
|
+
{ path: loginPath, query: { returnTo: to.fullPath } },
|
|
11
|
+
{ external: true, replace: true }
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
export default defineNuxtRouteMiddleware(middleware);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Plugin } from 'nuxt/app';
|
|
2
|
+
/**
|
|
3
|
+
* SSR only. Copies the session resolved by the Nitro middleware (`event.context.huia`) into
|
|
4
|
+
* `useState('huia-auth:session')`, which Nuxt serialises into the payload. The client then reads the
|
|
5
|
+
* identical value on first render — no fetch, no hydration mismatch, no flash.
|
|
6
|
+
*/
|
|
7
|
+
declare const plugin: Plugin;
|
|
8
|
+
export default plugin;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useState, useRequestEvent } from "#imports";
|
|
2
|
+
const plugin = defineNuxtPlugin(() => {
|
|
3
|
+
const event = useRequestEvent();
|
|
4
|
+
const state = useState("huia-auth:session", () => ({}));
|
|
5
|
+
const ctx = event?.context;
|
|
6
|
+
state.value = ctx?.huia ?? {};
|
|
7
|
+
});
|
|
8
|
+
export default plugin;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches `path` against a glob-ish exclude pattern: `**` matches any depth (including zero
|
|
3
|
+
* segments), `*` matches exactly one path segment, everything else is a literal. `/auth/**` matches
|
|
4
|
+
* `/auth`, `/auth/oidc/login` and anything else under `/auth`; `/about` matches only `/about`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function matchesPattern(path: string, pattern: string): boolean;
|
|
7
|
+
/** Whether `path` matches any pattern in `exclude`. */
|
|
8
|
+
export declare function isExcluded(path: string, exclude: string[]): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function matchesPattern(path, pattern) {
|
|
2
|
+
const regexSource = pattern.split("/").map(
|
|
3
|
+
(segment) => segment === "**" ? ".*" : segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")
|
|
4
|
+
).join("/").replace(/\/\.\*$/, "(?:/.*)?");
|
|
5
|
+
return new RegExp(`^${regexSource}$`).test(path);
|
|
6
|
+
}
|
|
7
|
+
export function isExcluded(path, exclude) {
|
|
8
|
+
return exclude.some((pattern) => matchesPattern(path, pattern));
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs on every request. Populates `event.context.huia` (memoised inside `getUserSession`) so the
|
|
3
|
+
* Nuxt server plugin can seed `useState('huia-auth:session')` synchronously during SSR. Errors are
|
|
4
|
+
* swallowed here so a transient storage/OP hiccup never 500s an otherwise-anonymous request.
|
|
5
|
+
*/
|
|
6
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineEventHandler, sendRedirect } from "h3";
|
|
2
|
+
import { resolveAuthConfig } from "../../../utils/config.js";
|
|
3
|
+
import { completeAuthorization } from "../../../utils/oidc.js";
|
|
4
|
+
import { setUserSession } from "../../../utils/session.js";
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const cfg = resolveAuthConfig(event);
|
|
7
|
+
try {
|
|
8
|
+
const { tokens, claims, returnTo } = await completeAuthorization(event);
|
|
9
|
+
await setUserSession(event, { tokens, claims });
|
|
10
|
+
return sendRedirect(event, returnTo || "/", 302);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
const message = err instanceof Error ? err.message : "";
|
|
13
|
+
const code = /state/i.test(message) ? "state_mismatch" : /issuer/i.test(message) ? "issuer_mismatch" : /par_expired|request_uri/i.test(message) ? "par_expired" : "callback_failed";
|
|
14
|
+
return sendRedirect(event, `${cfg.errorPath}?auth_error=${code}`, 302);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineEventHandler, getQuery, sendRedirect } from "h3";
|
|
2
|
+
import { resolveAuthConfig } from "../../../utils/config.js";
|
|
3
|
+
import { beginAuthorization } from "../../../utils/oidc.js";
|
|
4
|
+
import { sanitizeReturnTo } from "../../../utils/tokens.js";
|
|
5
|
+
export default defineEventHandler(async (event) => {
|
|
6
|
+
const cfg = resolveAuthConfig(event);
|
|
7
|
+
const q = getQuery(event);
|
|
8
|
+
const returnTo = sanitizeReturnTo(typeof q.returnTo === "string" ? q.returnTo : void 0);
|
|
9
|
+
const extra = {};
|
|
10
|
+
for (const key of cfg.allowedAuthParams) {
|
|
11
|
+
const v = q[key];
|
|
12
|
+
if (typeof v === "string" && v.length > 0) extra[key] = v;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const { redirectTo } = await beginAuthorization(event, { returnTo, extra });
|
|
16
|
+
return sendRedirect(event, redirectTo, 302);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
const reason = err instanceof Error && err.message.startsWith("par_required") ? "par_required" : "login_failed";
|
|
19
|
+
return sendRedirect(event, `${cfg.errorPath}?auth_error=${reason}`, 302);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineEventHandler, getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { resolveAuthConfig } from "../../../utils/config.js";
|
|
3
|
+
import { buildLogoutUrl } from "../../../utils/oidc.js";
|
|
4
|
+
import { clearUserSession, getSecureTokenRecord } from "../../../utils/session.js";
|
|
5
|
+
import { sanitizeReturnTo, isJwtExpired } from "../../../utils/tokens.js";
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const cfg = resolveAuthConfig(event);
|
|
8
|
+
const returnTo = sanitizeReturnTo(String(getQuery(event).returnTo ?? "/"));
|
|
9
|
+
const postLogoutRedirectUri = new URL(returnTo, getRequestURL(event).origin).href;
|
|
10
|
+
const record = await getSecureTokenRecord(event);
|
|
11
|
+
await clearUserSession(event);
|
|
12
|
+
if (cfg.logout.rpInitiated && record?.idToken && !isJwtExpired(record.idToken)) {
|
|
13
|
+
return sendRedirect(event, await buildLogoutUrl(event, {
|
|
14
|
+
idTokenHint: record.idToken,
|
|
15
|
+
postLogoutRedirectUri
|
|
16
|
+
}), 302);
|
|
17
|
+
}
|
|
18
|
+
if (cfg.logout.rpInitiated) {
|
|
19
|
+
return sendRedirect(event, await buildLogoutUrl(event, { postLogoutRedirectUri }), 302);
|
|
20
|
+
}
|
|
21
|
+
return sendRedirect(event, postLogoutRedirectUri, 302);
|
|
22
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type H3Event } from 'h3';
|
|
2
|
+
import type { ResolvedAuthConfig } from './internal-types.js';
|
|
3
|
+
interface RawHuiaAuth {
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
issuer: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
tenant: string;
|
|
9
|
+
redirectUrl: string;
|
|
10
|
+
scopes: string[];
|
|
11
|
+
allowedAuthParams: string[];
|
|
12
|
+
par: {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
required: boolean;
|
|
15
|
+
};
|
|
16
|
+
session: {
|
|
17
|
+
name: string;
|
|
18
|
+
password: string;
|
|
19
|
+
maxAge: number;
|
|
20
|
+
cookie: {
|
|
21
|
+
sameSite?: string;
|
|
22
|
+
secure?: boolean;
|
|
23
|
+
};
|
|
24
|
+
userClaims: string[];
|
|
25
|
+
};
|
|
26
|
+
storage: {
|
|
27
|
+
base: string;
|
|
28
|
+
};
|
|
29
|
+
refresh: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
earlyRefreshSeconds: number;
|
|
32
|
+
lock: {
|
|
33
|
+
ttlMs: number;
|
|
34
|
+
waitMs: number;
|
|
35
|
+
pollMs: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
cookie: {
|
|
39
|
+
chunkSize: number;
|
|
40
|
+
maxChunks: number;
|
|
41
|
+
};
|
|
42
|
+
routes: {
|
|
43
|
+
login: string;
|
|
44
|
+
callback: string;
|
|
45
|
+
logout: string;
|
|
46
|
+
session: string;
|
|
47
|
+
error: string;
|
|
48
|
+
};
|
|
49
|
+
allowInsecureTls: boolean;
|
|
50
|
+
logout?: {
|
|
51
|
+
rpInitiated?: boolean;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** `{baseUrl}/{tenant}` (or an explicit `issuer`), with any trailing slash stripped. */
|
|
55
|
+
export declare function resolveIssuer(raw: Pick<RawHuiaAuth, 'issuer' | 'baseUrl' | 'tenant'>): string;
|
|
56
|
+
export declare function discoveryCacheKey(issuer: string): string;
|
|
57
|
+
export declare function resolveAuthConfig(event: H3Event): ResolvedAuthConfig;
|
|
58
|
+
export {};
|