diff --git a/app/composables/useOrganizationSelection.ts b/app/composables/useOrganizationSelection.ts index cd6610f..6c47388 100644 --- a/app/composables/useOrganizationSelection.ts +++ b/app/composables/useOrganizationSelection.ts @@ -16,20 +16,18 @@ export function useOrganizationSelection() { const { data: session } = useAuth() const organizations = computed(() => { - const allOrgRoles = (session.value?.user as any)?.allOrgRoles as Record>> | undefined + const allOrgRoles = (session.value?.user as any)?.roles as Record> | undefined if (!allOrgRoles) { return [] } const result: OrganizationInfo[] = [] - for (const roles of Object.values(allOrgRoles)) { - for (const [role, orgMap] of Object.entries(roles)) { + for (const [role, orgMap] of Object.entries(allOrgRoles)) { for (const [orgId, orgDomain] of Object.entries(orgMap)) { - if (!result.find(o => o.orgId === orgId)) { - result.push({ orgId, orgDomain: orgDomain as string, role, orgSubDomain: extractSubdomain(orgDomain) }) - } + if (!result.find(o => o.orgId === orgId)) { + result.push({ orgId, orgDomain: orgDomain as string, role, orgSubDomain: extractSubdomain(orgDomain) }) + } } - } } result.sort((a, b) => a.orgDomain.localeCompare(b.orgDomain)) return result diff --git a/flake.nix b/flake.nix index 932ad0a..1faa73d 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ inherit pname version src; pnpm = pkgs.pnpm; fetcherVersion = 3; - hash = "sha256-QjmYXOv8JliJhN+5XfA1+OnBIf4I/6s84LSisJlBdTo="; + hash = "sha256-Z045R87Mgu4FReVqcgCn2PR4THMlcZoG3NEuBy6JEwI="; }; env.NUXT_TELEMETRY_DISABLED = 1; diff --git a/package.json b/package.json index 8304003..e987da8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@nuxt/ui": "^4.5.0", "@zitadel/nuxt-auth": "^1.0.0", "jspdf": "^4.2.0", + "jwt-decode": "^4.0.0", "nuxt": "^4.3.1", "nuxt-open-fetch": "^0.13.8", "tailwindcss": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cdac88..ff57cea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,13 +16,16 @@ importers: version: 1.15.2(@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.32)(eslint@10.2.1(jiti@2.6.1))(magicast@0.5.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3)) '@nuxt/ui': specifier: ^4.5.0 - version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6) + version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6) '@zitadel/nuxt-auth': specifier: ^1.0.0 version: 1.0.0(@auth/core@0.40.0)(@nuxt/schema@4.4.2)(magicast@0.5.2)(radix3@1.1.2)(vue@3.5.32(typescript@5.9.3)) jspdf: specifier: ^4.2.0 version: 4.2.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 nuxt: specifier: ^4.3.1 version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4)(eslint@10.2.1(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(srvx@0.11.15)(terser@5.46.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(yaml@2.8.3) @@ -3415,6 +3418,10 @@ packages: jspdf@4.2.1: resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6019,7 +6026,7 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)': + '@nuxt/ui@4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(jwt-decode@4.0.0)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@4.6.4(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))(yjs@13.6.30)(zod@4.3.6)': dependencies: '@floating-ui/dom': 1.7.6 '@iconify/vue': 5.0.0(vue@3.5.32(typescript@5.9.3)) @@ -6054,7 +6061,7 @@ snapshots: '@tiptap/vue-3': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(vue@3.5.32(typescript@5.9.3)) '@unhead/vue': 2.1.13(vue@3.5.32(typescript@5.9.3)) '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) - '@vueuse/integrations': 14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(vue@3.5.32(typescript@5.9.3)) + '@vueuse/integrations': 14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(jwt-decode@4.0.0)(vue@3.5.32(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) colortranslator: 5.0.0 consola: 3.4.2 @@ -7359,7 +7366,7 @@ snapshots: '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) vue: 3.5.32(typescript@5.9.3) - '@vueuse/integrations@14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(vue@3.5.32(typescript@5.9.3))': + '@vueuse/integrations@14.2.1(change-case@5.4.4)(fuse.js@7.3.0)(jwt-decode@4.0.0)(vue@3.5.32(typescript@5.9.3))': dependencies: '@vueuse/core': 14.2.1(vue@3.5.32(typescript@5.9.3)) '@vueuse/shared': 14.2.1(vue@3.5.32(typescript@5.9.3)) @@ -7367,6 +7374,7 @@ snapshots: optionalDependencies: change-case: 5.4.4 fuse.js: 7.3.0 + jwt-decode: 4.0.0 '@vueuse/metadata@10.11.1': {} @@ -8631,6 +8639,8 @@ snapshots: dompurify: 3.4.0 html2canvas: 1.4.1 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 diff --git a/server/api/auth/[...].ts b/server/api/auth/[...].ts index f13abd5..2f9266d 100644 --- a/server/api/auth/[...].ts +++ b/server/api/auth/[...].ts @@ -1,5 +1,6 @@ import { NuxtAuthHandler } from '#auth' import ZitadelProvider from '@auth/core/providers/zitadel' +import { jwtDecode } from 'jwt-decode' const config = useRuntimeConfig() @@ -12,7 +13,7 @@ export default NuxtAuthHandler({ pkce: true, authorization: { params: { - scope: `openid email profile offline_access urn:zitadel:iam:org:project:${config.projectId}:aud` + scope: `openid email profile offline_access urn:zitadel:iam:org:project:${config.zitadelProjectId}:aud urn:zitadel:iam:org:project:${config.zitadelProjectId}:roles` } } }) @@ -26,34 +27,6 @@ export default NuxtAuthHandler({ if (account?.provider === 'zitadel') { token.accessToken = account.access_token token.idToken = account.id_token - token.roles = (user as any)?.roles - - /* Extract org roles from ID token claims */ - const allOrgRoles: Record>> = {} - const idTokenClaims = (() => { - try { - const parts = (account.id_token || '').split('.') - if (parts.length === 3) { - const payload = Buffer.from(parts[1], 'base64url').toString('utf8') - return JSON.parse(payload) as Record - } - } catch { /* ignore */ } - return null - })() - if (idTokenClaims) { - for (const key of Object.keys(idTokenClaims)) { - if (key.startsWith('urn:zitadel:iam:org:project:') && key.endsWith(':roles')) { - allOrgRoles[key] = idTokenClaims[key] - } - } - } - /* Also check userinfo response for org role claims */ - for (const key of Object.keys((user as any) || {})) { - if (key.startsWith('urn:zitadel:iam:org:project:') && key.endsWith(':roles')) { - allOrgRoles[key] = (user as any)[key] - } - } - token.allOrgRoles = Object.keys(allOrgRoles).length > 0 ? allOrgRoles : undefined } if (user?.id) { token.sub = user.id @@ -71,9 +44,18 @@ export default NuxtAuthHandler({ user.name = token.name || undefined user.email = token.email || undefined user.image = token.image || undefined - user.roles = token.roles as string[] | undefined user.accessToken = token.accessToken as string | undefined - user.allOrgRoles = token.allOrgRoles as Record>> | undefined + + // Decode idToken and extract org roles claim + if (token.idToken) { + try { + const decoded = jwtDecode(token.idToken) + const roles = decoded[`urn:zitadel:iam:org:project:${config.zitadelProjectId}:roles`] + user.roles = roles + } catch (error) { + console.error('Failed to decode idToken:', error) + } + } } return session }, diff --git a/types/auth.d.ts b/types/auth.d.ts index b40676e..d89a8ae 100644 --- a/types/auth.d.ts +++ b/types/auth.d.ts @@ -3,21 +3,21 @@ import type { DefaultSession } from '@auth/core/types' declare module '@auth/core/types' { interface Session { user: { - roles?: string[] + roles?: Record>> accessToken?: string } & DefaultSession['user'] } interface User { - roles?: string[] + roles?: Record>> } } declare module '#auth' { interface Session { user: { - roles?: string[] + roles?: Record>> accessToken?: string } & DefaultSession['user'] } -} \ No newline at end of file +}