From e5556063cc635a8ed0a719aaed823d6368504548 Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 9 Mar 2026 10:54:27 +0200 Subject: [PATCH 01/13] Get cms to work with multiple auth provider support --- api/package-lock.json | 53 +- api/package.json | 1 + api/src/app.module.ts | 4 + api/src/auth/auth-identity.service.ts | 241 +++++ api/src/auth/auth.guard.ts | 109 ++- api/src/changeRequests/aclValidation.ts | 21 +- .../changeRequests/validateChangeRequest.ts | 2 + api/src/db/db.upgrade.ts | 11 +- .../sync-oAuthProvider-deleteCmd-index.json | 27 + .../designDocs/sync-oAuthProvider-index.json | 24 + api/src/db/schemaUpgrade/v13.ts | 86 ++ api/src/db/seedingDocs/group-languages.json | 5 + .../db/seedingDocs/group-private-content.json | 5 + .../db/seedingDocs/group-private-editors.json | 5 + .../db/seedingDocs/group-private-users.json | 5 + .../db/seedingDocs/group-public-content.json | 10 + .../db/seedingDocs/group-public-editors.json | 5 + .../db/seedingDocs/group-public-users.json | 10 + .../db/seedingDocs/group-super-admins.json | 15 + api/src/dto/OAuthProviderDto.ts | 143 +++ api/src/dto/UserDto.ts | 11 +- api/src/endpoints/changeRequest.controller.ts | 8 +- api/src/endpoints/changeRequest.service.ts | 33 +- api/src/endpoints/query.controller.ts | 20 +- api/src/endpoints/query.service.ts | 8 +- api/src/endpoints/search.controller.ts | 14 +- api/src/endpoints/search.service.ts | 10 +- api/src/endpoints/storageStatus.controller.ts | 17 +- api/src/enums.ts | 1 + api/src/jwt/processJwt.ts | 12 +- api/src/permissions/permissions.service.ts | 3 + api/src/socketio.ts | 83 +- app/src/App.vue | 3 + app/src/auth.ts | 51 +- .../navigation/ProviderSelectionModal.vue | 94 ++ .../composables/useAuthWithPrivacyPolicy.ts | 9 +- app/src/main.ts | 12 + app/src/stores/authProvider.ts | 57 ++ app/src/sync.ts | 13 + cms/src/App.vue | 2 + cms/src/auth.ts | 169 +++- .../navigation/ProviderSelectionModal.vue | 93 ++ .../OAuthProviderDisplayCard.vue | 184 ++++ .../OAuthProviderFormModal.spec.ts | 118 +++ .../oAuthProvider/OAuthProviderFormModal.vue | 918 ++++++++++++++++++ .../OAuthProviderOverview.spec.ts | 188 ++++ .../oAuthProvider/OAuthProviderOverview.vue | 412 ++++++++ cms/src/main.ts | 22 +- cms/src/pages/OAuthProviderPage.vue | 47 + cms/src/stores/authProvider.ts | 57 ++ cms/src/sync.ts | 14 + cms/src/tests/mockdata.ts | 19 + scripts/setup.sh | 185 ++++ shared/src/types/dto.ts | 60 ++ shared/src/types/enum.ts | 1 + 55 files changed, 3623 insertions(+), 107 deletions(-) create mode 100644 api/src/auth/auth-identity.service.ts create mode 100644 api/src/db/designDocs/sync-oAuthProvider-deleteCmd-index.json create mode 100644 api/src/db/designDocs/sync-oAuthProvider-index.json create mode 100644 api/src/db/schemaUpgrade/v13.ts create mode 100644 api/src/dto/OAuthProviderDto.ts create mode 100644 app/src/components/navigation/ProviderSelectionModal.vue create mode 100644 app/src/stores/authProvider.ts create mode 100644 cms/src/components/navigation/ProviderSelectionModal.vue create mode 100644 cms/src/components/oAuthProvider/OAuthProviderDisplayCard.vue create mode 100644 cms/src/components/oAuthProvider/OAuthProviderFormModal.spec.ts create mode 100644 cms/src/components/oAuthProvider/OAuthProviderFormModal.vue create mode 100644 cms/src/components/oAuthProvider/OAuthProviderOverview.spec.ts create mode 100644 cms/src/components/oAuthProvider/OAuthProviderOverview.vue create mode 100644 cms/src/pages/OAuthProviderPage.vue create mode 100644 cms/src/stores/authProvider.ts create mode 100755 scripts/setup.sh diff --git a/api/package-lock.json b/api/package-lock.json index 6c9efbd3d6..cb65570480 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -18,6 +18,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^4.0.1", "minio": "^8.0.0", "music-metadata": "^11.9.0", "nano": "^10.1.4", @@ -8456,6 +8457,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", + "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8586,6 +8596,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.0.1.tgz", + "integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, "node_modules/jws": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", @@ -8689,6 +8715,11 @@ ], "license": "MIT" }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8751,8 +8782,7 @@ "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "dev": true + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -8844,6 +8874,25 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/api/package.json b/api/package.json index fa20355931..a42b587395 100644 --- a/api/package.json +++ b/api/package.json @@ -33,6 +33,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^4.0.1", "minio": "^8.0.0", "music-metadata": "^11.9.0", "nano": "^10.1.4", diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 89759b9518..919e5f1143 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -15,6 +15,8 @@ import * as winston from "winston"; import { QueryService } from "./endpoints/query.service"; import { QueryController } from "./endpoints/query.controller"; import { StorageStatusController } from "./endpoints/storageStatus.controller"; +import { AuthIdentityService } from "./auth/auth-identity.service"; +import { AuthGuard } from "./auth/auth.guard"; let winstonTransport: winston.transport; if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { @@ -63,6 +65,8 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") { SearchService, QueryService, ChangeRequestService, + AuthIdentityService, + AuthGuard, ], }) export class AppModule {} diff --git a/api/src/auth/auth-identity.service.ts b/api/src/auth/auth-identity.service.ts new file mode 100644 index 0000000000..af628c0cfc --- /dev/null +++ b/api/src/auth/auth-identity.service.ts @@ -0,0 +1,241 @@ +import { Injectable } from "@nestjs/common"; +import { JwtPayload } from "jsonwebtoken"; +import { randomUUID } from "crypto"; +import { DbService } from "../db/db.service"; +import { DocType } from "../enums"; +import { GroupAssignment, GroupAssignmentCondition, OAuthProviderDto } from "../dto/OAuthProviderDto"; +import { UserDto } from "../dto/UserDto"; + +/** + * The resolved identity returned after JWT processing. + * Attach this to the request object so downstream handlers can read user + groups. + */ +export type ResolvedIdentity = { + user: UserDto; + /** Group IDs that passed all conditions in groupAssignments. Falls back to the guest group. */ + groupIds: string[]; +}; + +/** Fallback group assigned when no groupAssignment conditions match. */ +const GUEST_GROUP_ID = "group-public-users"; + +@Injectable() +export class AuthIdentityService { + constructor(private readonly db: DbService) {} + + /** + * Main entry point. Given a verified JWT payload and the matching OAuthProvider document, + * resolves the user identity (find-or-provision) and evaluates all groupAssignment rules. + */ + async resolveIdentity(payload: JwtPayload, provider: OAuthProviderDto): Promise { + const { userId, email, name } = this.extractUserFields(payload, provider); + const user = await this.findOrProvisionUser({ userId, email, name, providerId: provider._id }); + const groupIds = this.evaluateGroupAssignments(payload, provider.groupAssignments ?? []); + + return { user, groupIds }; + } + + // ------------------------------------------------------------------------- + // Identity resolution + // ------------------------------------------------------------------------- + + /** + * Extracts userId, email, and name from the JWT payload using the provider's + * claimNamespace and userFieldMappings configuration. + * + * Auth0 supports two namespacing conventions: + * 1. Nested object: payload["https://ns.example.com"] = { userId, email, name } + * 2. Flat prefix: payload["https://ns.example.com/userId"] = "value" + * + * Both are tried; the nested form takes precedence. + */ + private extractUserFields( + payload: JwtPayload, + provider: OAuthProviderDto, + ): { userId: string; email: string; name: string } { + const mappings = provider.userFieldMappings ?? {}; + const ns = provider.claimNamespace; + + const get = (fieldName: string | undefined): string => { + if (!fieldName) return ""; + + // 1. Try namespaced nested object: payload[ns][fieldName] + if (ns) { + const nsObj = payload[ns]; + if (nsObj && typeof nsObj === "object" && !Array.isArray(nsObj)) { + const val = (nsObj as Record)[fieldName]; + if (typeof val === "string" && val) return val; + } + + // 2. Try flat namespaced key: payload["ns/fieldName"] + const flatKey = `${ns}/${fieldName}`; + const flatVal = payload[flatKey]; + if (typeof flatVal === "string" && flatVal) return flatVal; + } + + // 3. Fall back to top-level key (e.g. standard JWT claims like "sub", "email") + const topVal = payload[fieldName]; + if (typeof topVal === "string" && topVal) return topVal; + + return ""; + }; + + return { + userId: get(mappings.userId) || (payload.sub ?? ""), + email: get(mappings.email), + name: get(mappings.name), + }; + } + + /** + * Looks up an existing user by (oAuthProviderId, userId) or creates a new one. + * The combination of provider ID + external user ID is globally unique. + */ + private async findOrProvisionUser(opts: { + userId: string; + email: string; + name: string; + providerId: string; + }): Promise { + const { userId, email, name, providerId } = opts; + + // Query by the compound key (oAuthProviderId + userId) + const result = await this.db.executeFindQuery({ + selector: { + type: DocType.User, + oAuthProviderId: providerId, + userId, + }, + limit: 1, + }); + + if (result.docs?.length > 0) { + const existing = result.docs[0] as UserDto; + + // Refresh mutable fields that may have changed in the identity provider + const needsUpdate = + (email && existing.email !== email) || + (name && existing.name !== name); + + if (needsUpdate) { + const updated: UserDto = { + ...existing, + ...(email ? { email } : {}), + ...(name ? { name } : {}), + lastLogin: Date.now(), + }; + await this.db.upsertDoc(updated); + return updated; + } + + // Touch lastLogin without triggering a full update cycle + await this.db.upsertDoc({ ...existing, lastLogin: Date.now() }); + return existing; + } + + // Provision a new user document + const newUser: UserDto = { + _id: randomUUID(), + type: DocType.User, + memberOf: [], + oAuthProviderId: providerId, + userId, + email: email || `${userId}@unknown`, + name: name || userId, + lastLogin: Date.now(), + }; + + await this.db.upsertDoc(newUser); + return newUser; + } + + // ------------------------------------------------------------------------- + // Permission engine + // ------------------------------------------------------------------------- + + /** + * Iterates over every GroupAssignment and evaluates its conditions against the JWT payload. + * All conditions within a single assignment must pass (AND semantics). + * + * Returns the deduplicated set of groupIds where all conditions passed. + * Falls back to [GUEST_GROUP_ID] when nothing matches. + */ + private evaluateGroupAssignments(payload: JwtPayload, assignments: GroupAssignment[]): string[] { + const matched = new Set(); + + for (const assignment of assignments) { + const allPass = assignment.conditions.every((cond) => + this.evaluateCondition(cond, payload), + ); + if (allPass) { + matched.add(assignment.groupId); + } + } + + return matched.size > 0 ? [...matched] : [GUEST_GROUP_ID]; + } + + /** + * Evaluates a single GroupAssignmentCondition against the JWT payload. + * + * | type | semantics | + * |---------------|---------------------------------------------------------| + * | always | always true | + * | authenticated | true when the payload has a non-empty "sub" claim | + * | claimEquals | claim value (string) strictly equals condition value | + * | claimIn | claim value is contained in the condition values array | + */ + private evaluateCondition(condition: GroupAssignmentCondition, payload: JwtPayload): boolean { + switch (condition.type) { + case "always": + return true; + + case "authenticated": + return typeof payload.sub === "string" && payload.sub.length > 0; + + case "claimEquals": { + const val = this.resolveClaim(payload, condition.claimPath); + return val === condition.value; + } + + case "claimIn": { + const val = this.resolveClaim(payload, condition.claimPath); + if (Array.isArray(val)) { + // If the claim is itself an array, check for any overlap + return val.some((v) => condition.values.includes(String(v))); + } + return condition.values.includes(String(val)); + } + } + } + + /** + * Resolves a claimPath against the JWT payload. + * + * claimPath can be: + * - A top-level JWT key: "sub", "email", "https://example.com/roles" + * - A dot-notation nested path: "https://example.com/metadata.role" + * (splits on the LAST dot so URL-style namespaces are preserved) + * + * Returns the raw value (string | string[] | unknown) or undefined when not found. + */ + private resolveClaim(payload: JwtPayload, claimPath: string): unknown { + // First try the full path as a direct key (handles URL-shaped namespaced claims) + if (Object.prototype.hasOwnProperty.call(payload, claimPath)) { + return payload[claimPath]; + } + + // Fall back to dot-notation traversal (split on last dot only to preserve URLs) + const lastDot = claimPath.lastIndexOf("."); + if (lastDot !== -1) { + const parentKey = claimPath.slice(0, lastDot); + const childKey = claimPath.slice(lastDot + 1); + const parent = payload[parentKey]; + if (parent && typeof parent === "object" && !Array.isArray(parent)) { + return (parent as Record)[childKey]; + } + } + + return undefined; + } +} diff --git a/api/src/auth/auth.guard.ts b/api/src/auth/auth.guard.ts index 618493c2f6..e7cd6ec58f 100644 --- a/api/src/auth/auth.guard.ts +++ b/api/src/auth/auth.guard.ts @@ -1,26 +1,51 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; import { FastifyRequest } from "fastify"; +import * as jwt from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; +import { DbService } from "../db/db.service"; +import { DocType } from "../enums"; +import { OAuthProviderDto } from "../dto/OAuthProviderDto"; +import { AuthIdentityService, ResolvedIdentity } from "./auth-identity.service"; +import { UserDto } from "../dto/UserDto"; + +const GUEST_GROUP_ID = "group-public-users"; + +const GUEST_IDENTITY: ResolvedIdentity = { + user: { _id: "guest", type: DocType.User, email: "", name: "Guest", memberOf: [] } as UserDto, + groupIds: [GUEST_GROUP_ID], +}; @Injectable() export class AuthGuard implements CanActivate { - constructor(private jwtService: JwtService) {} + constructor( + private db: DbService, + private identity: AuthIdentityService, + ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + // No token → guest access (mirrors socket.io gateway behaviour) if (!token) { + (request as any)["user"] = GUEST_IDENTITY; + return true; + } + + const provider = await this.resolveProvider(token, request); + if (!provider) { throw new UnauthorizedException(); } - try { - const payload = await this.jwtService.verifyAsync(token, { - secret: process.env.JWT_SECRET, - }); - (request as any)["user"] = payload; + try { + const payload = await this.verifyToken(token, provider); + const identity = await this.identity.resolveIdentity(payload, provider); + (request as any)["user"] = identity; } catch { throw new UnauthorizedException(); } + return true; } @@ -28,4 +53,74 @@ export class AuthGuard implements CanActivate { const [type, token] = request.headers.authorization?.split(" ") ?? []; return type === "Bearer" ? token : undefined; } + + /** + * Find the OAuthProvider for this request. + * Prefers the `x-query` header (explicit provider ID), then falls back to + * the `iss` claim in the JWT (same strategy used by the socket.io gateway). + */ + private async resolveProvider( + token: string, + request: FastifyRequest, + ): Promise { + const providerId = request.headers["x-query"] as string | undefined; + if (providerId) { + return this.findProviderById(providerId); + } + + // Fall back: decode the token (without verifying) to extract the issuer domain + const decoded = jwt.decode(token) as jwt.JwtPayload | null; + if (!decoded?.iss) return null; + + try { + const domain = new URL(decoded.iss).hostname; + return this.findProviderByDomain(domain); + } catch { + return null; + } + } + + private async findProviderById(id: string): Promise { + const result = await this.db.executeFindQuery({ + selector: { _id: id, type: DocType.OAuthProvider }, + limit: 1, + }); + return (result.docs?.[0] as OAuthProviderDto) ?? null; + } + + private async findProviderByDomain(domain: string): Promise { + const result = await this.db.executeFindQuery({ + selector: { type: DocType.OAuthProvider, domain }, + limit: 1, + }); + return (result.docs?.[0] as OAuthProviderDto) ?? null; + } + + private verifyToken(token: string, provider: OAuthProviderDto): Promise { + if (provider.providerType !== "auth0") { + throw new UnauthorizedException(); + } + + const jwksUri = `https://${provider.domain}/.well-known/jwks.json`; + const client = new JwksClient({ jwksUri }); + + const getKey: jwt.GetPublicKeyOrSecret = (header, callback) => { + client.getSigningKey(header.kid, (err, key) => { + if (err) return callback(err); + callback(null, key.getPublicKey()); + }); + }; + + const options: jwt.VerifyOptions = {}; + if (provider.audience) { + options.audience = provider.audience; + } + + return new Promise((resolve, reject) => { + jwt.verify(token, getKey, options, (err, decoded) => { + if (err) return reject(err); + resolve(decoded as jwt.JwtPayload); + }); + }); + } } diff --git a/api/src/changeRequests/aclValidation.ts b/api/src/changeRequests/aclValidation.ts index f3805d3ed5..6c9438ad6f 100644 --- a/api/src/changeRequests/aclValidation.ts +++ b/api/src/changeRequests/aclValidation.ts @@ -35,8 +35,25 @@ const availablePermissionsPerDocType = { AclPermission.Translate, AclPermission.Publish, ], - [DocType.User]: [AclPermission.View, AclPermission.Create, AclPermission.Edit, AclPermission.Delete], - [DocType.Redirect]: [AclPermission.View, AclPermission.Create, AclPermission.Edit, AclPermission.Delete], + [DocType.User]: [ + AclPermission.View, + AclPermission.Create, + AclPermission.Edit, + AclPermission.Delete, + ], + [DocType.OAuthProvider]: [ + AclPermission.View, + AclPermission.Create, + AclPermission.Edit, + AclPermission.Delete, + AclPermission.Assign, + ], + [DocType.Redirect]: [ + AclPermission.View, + AclPermission.Create, + AclPermission.Edit, + AclPermission.Delete, + ], [DocType.Storage]: [ AclPermission.View, AclPermission.Create, diff --git a/api/src/changeRequests/validateChangeRequest.ts b/api/src/changeRequests/validateChangeRequest.ts index be750cdf60..cacb4930e3 100644 --- a/api/src/changeRequests/validateChangeRequest.ts +++ b/api/src/changeRequests/validateChangeRequest.ts @@ -14,6 +14,7 @@ import { validateChangeRequestAccess } from "./validateChangeRequestAccess"; import { validateAcl } from "./aclValidation"; import { RedirectDto } from "../dto/RedirectDto"; import { StorageDto } from "../dto/StorageDto"; +import { OAuthProviderDto } from "../dto/OAuthProviderDto"; /** * DocType to DTO map @@ -27,6 +28,7 @@ const DocTypeMap = { tag: TagDto, user: UserDto, storage: StorageDto, + oAuthProvider: OAuthProviderDto, }; /** diff --git a/api/src/db/db.upgrade.ts b/api/src/db/db.upgrade.ts index dd1ba1cfa7..ff466d16de 100644 --- a/api/src/db/db.upgrade.ts +++ b/api/src/db/db.upgrade.ts @@ -3,6 +3,7 @@ import v9 from "./schemaUpgrade/v9"; import v10 from "./schemaUpgrade/v10"; import v11 from "./schemaUpgrade/v11"; import v12 from "./schemaUpgrade/v12"; +import v13 from "./schemaUpgrade/v13"; /** * Upgrade the database schema @@ -10,10 +11,18 @@ import v12 from "./schemaUpgrade/v12"; */ export async function upgradeDbSchema(db: DbService) { try { - await v9(db); + // If no schema document exists yet, create it at version 8 so the upgrade chain can run + const schemaVersion = await db.getSchemaVersion(); + if (schemaVersion === 8) { + console.info("No schema document found, creating at version 8 to start upgrade chain"); + await db.setSchemaVersion(9); + } + + // await v9(db); await v10(db); await v11(db); await v12(db); + await v13(db); } catch (error) { console.error("Database schema upgrade failed:", error); throw error; // Re-throw to prevent schema version from being updated diff --git a/api/src/db/designDocs/sync-oAuthProvider-deleteCmd-index.json b/api/src/db/designDocs/sync-oAuthProvider-deleteCmd-index.json new file mode 100644 index 0000000000..799b27f689 --- /dev/null +++ b/api/src/db/designDocs/sync-oAuthProvider-deleteCmd-index.json @@ -0,0 +1,27 @@ +{ + "_id": "_design/sync-oAuthProvider-deleteCmd-index", + "language": "query", + "views": { + "sync-oAuthProvider-deleteCmd-index": { + "map": { + "fields": { + "updatedTimeUtc": "desc" + }, + "partial_filter_selector": { + "type": { + "$eq": "deleteCmd" + }, + "docType": { + "$eq": "oAuthProvider" + } + } + }, + "reduce": "_count", + "options": { + "def": { + "fields": ["updatedTimeUtc"] + } + } + } + } +} diff --git a/api/src/db/designDocs/sync-oAuthProvider-index.json b/api/src/db/designDocs/sync-oAuthProvider-index.json new file mode 100644 index 0000000000..050d79564a --- /dev/null +++ b/api/src/db/designDocs/sync-oAuthProvider-index.json @@ -0,0 +1,24 @@ +{ + "_id": "_design/sync-oAuthProvider-index", + "language": "query", + "views": { + "sync-oAuthProvider-index": { + "map": { + "fields": { + "updatedTimeUtc": "desc" + }, + "partial_filter_selector": { + "type": { + "$eq": "oAuthProvider" + } + } + }, + "reduce": "_count", + "options": { + "def": { + "fields": ["updatedTimeUtc"] + } + } + } + } +} diff --git a/api/src/db/schemaUpgrade/v13.ts b/api/src/db/schemaUpgrade/v13.ts new file mode 100644 index 0000000000..ff198184f6 --- /dev/null +++ b/api/src/db/schemaUpgrade/v13.ts @@ -0,0 +1,86 @@ +import { DbService } from "../db.service"; +import { AclPermission, DocType } from "../../enums"; + +type AclEntry = { + type: DocType; + groupId: string; + permission: AclPermission[]; +}; + +// oAuthProvider ACL entries that should exist in each group +const requiredAclEntries: Record = { + "group-public-content": [ + { type: DocType.OAuthProvider, groupId: "group-public-users", permission: [AclPermission.View] }, + { type: DocType.OAuthProvider, groupId: "group-private-users", permission: [AclPermission.View] }, + ], + "group-public-users": [ + { type: DocType.OAuthProvider, groupId: "group-public-users", permission: [AclPermission.View] }, + { type: DocType.OAuthProvider, groupId: "group-private-users", permission: [AclPermission.View] }, + ], + "group-super-admins": [ + { + type: DocType.OAuthProvider, + groupId: "group-super-admins", + permission: [AclPermission.View, AclPermission.Create, AclPermission.Edit, AclPermission.Delete, AclPermission.Assign], + }, + { type: DocType.OAuthProvider, groupId: "group-public-users", permission: [AclPermission.View] }, + { type: DocType.OAuthProvider, groupId: "group-private-users", permission: [AclPermission.View] }, + ], +}; + +/** + * Upgrade the database schema from version 12 to 13. + * Restores oAuthProvider ACL entries in group documents that had them stripped by + * a previous version of aclValidation that did not include DocType.OAuthProvider. + */ +export default async function (db: DbService) { + try { + const schemaVersion = await db.getSchemaVersion(); + if (schemaVersion !== 12) { + console.info(`Skipping schema upgrade v13: current version is ${schemaVersion}, expected 12`); + return; + } + + console.info("Upgrading database schema from version 12 to 13"); + + for (const [groupId, entries] of Object.entries(requiredAclEntries)) { + try { + const result = await db.getDoc(groupId); + if (!result.docs || result.docs.length === 0) { + console.info(`Group ${groupId} not found, skipping`); + continue; + } + + const group = result.docs[0]; + if (!group.acl) group.acl = []; + + let modified = false; + for (const entry of entries) { + const exists = group.acl.some( + (a: AclEntry) => a.type === entry.type && a.groupId === entry.groupId, + ); + if (!exists) { + group.acl.push(entry); + modified = true; + } + } + + if (modified) { + await db.insertDoc(group); + console.info(`Restored oAuthProvider ACL entries in ${groupId}`); + } else { + console.info(`Group ${groupId} already has oAuthProvider ACL entries, skipping`); + } + } catch (error) { + console.error(`Failed to update group ${groupId}:`, error); + throw error; + } + } + + await db.setSchemaVersion(13); + console.info("Database schema upgrade from version 12 to 13 completed successfully"); + } catch (error) { + console.error("Database schema upgrade from version 12 to 13 failed:", error); + throw error; + } +} diff --git a/api/src/db/seedingDocs/group-languages.json b/api/src/db/seedingDocs/group-languages.json index cf5c586694..a722db8d0d 100644 --- a/api/src/db/seedingDocs/group-languages.json +++ b/api/src/db/seedingDocs/group-languages.json @@ -42,6 +42,11 @@ "type": "storage", "groupId": "group-private-editors", "permission": ["view"] + }, + { + "type": "OAuthProvider", + "groupId": "group-private-editors", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-private-content.json b/api/src/db/seedingDocs/group-private-content.json index cefe2aa6d9..9e46a3bb71 100644 --- a/api/src/db/seedingDocs/group-private-content.json +++ b/api/src/db/seedingDocs/group-private-content.json @@ -42,6 +42,11 @@ "type": "redirect", "groupId": "group-private-editors", "permission": ["view", "edit"] + }, + { + "type": "OAuthProvider", + "groupId": "group-private-editors", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-private-editors.json b/api/src/db/seedingDocs/group-private-editors.json index 2174c964da..2e4e2dd6c4 100644 --- a/api/src/db/seedingDocs/group-private-editors.json +++ b/api/src/db/seedingDocs/group-private-editors.json @@ -37,6 +37,11 @@ "type": "storage", "groupId": "group-super-admins", "permission": ["view", "edit"] + }, + { + "type": "OAuthProvider", + "groupId": "group-super-admins", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-private-users.json b/api/src/db/seedingDocs/group-private-users.json index 0df1448c3c..cd4c2c48f4 100644 --- a/api/src/db/seedingDocs/group-private-users.json +++ b/api/src/db/seedingDocs/group-private-users.json @@ -37,6 +37,11 @@ "type": "storage", "groupId": "group-super-admins", "permission": ["view", "edit", "delete", "assign"] + }, + { + "type": "OAuthProvider", + "groupId": "group-super-admins", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-public-content.json b/api/src/db/seedingDocs/group-public-content.json index 4d79add350..0f761fd71c 100644 --- a/api/src/db/seedingDocs/group-public-content.json +++ b/api/src/db/seedingDocs/group-public-content.json @@ -77,6 +77,16 @@ "type": "storage", "groupId": "group-private-users", "permission": ["view"] + }, + { + "type": "oAuthProvider", + "groupId": "group-public-users", + "permission": ["view"] + }, + { + "type": "oAuthProvider", + "groupId": "group-private-users", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-public-editors.json b/api/src/db/seedingDocs/group-public-editors.json index 4774db5666..03732720e2 100644 --- a/api/src/db/seedingDocs/group-public-editors.json +++ b/api/src/db/seedingDocs/group-public-editors.json @@ -32,6 +32,11 @@ "type": "redirect", "groupId": "group-super-admins", "permission": ["view", "edit"] + }, + { + "type": "OAuthProvider", + "groupId": "group-super-admins", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-public-users.json b/api/src/db/seedingDocs/group-public-users.json index 4c178fcc33..067fb9c376 100644 --- a/api/src/db/seedingDocs/group-public-users.json +++ b/api/src/db/seedingDocs/group-public-users.json @@ -37,6 +37,16 @@ "type": "storage", "groupId": "group-super-admins", "permission": ["view", "edit", "delete", "assign"] + }, + { + "type": "oAuthProvider", + "groupId": "group-public-users", + "permission": ["view"] + }, + { + "type": "oAuthProvider", + "groupId": "group-private-users", + "permission": ["view"] } ] } diff --git a/api/src/db/seedingDocs/group-super-admins.json b/api/src/db/seedingDocs/group-super-admins.json index b997ec48b7..619ed0409c 100644 --- a/api/src/db/seedingDocs/group-super-admins.json +++ b/api/src/db/seedingDocs/group-super-admins.json @@ -42,6 +42,21 @@ "type": "storage", "groupId": "group-super-admins", "permission": ["view", "edit", "delete", "assign"] + }, + { + "type": "oAuthProvider", + "groupId": "group-super-admins", + "permission": ["view", "edit", "delete", "assign"] + }, + { + "type": "oAuthProvider", + "groupId": "group-public-users", + "permission": ["view"] + }, + { + "type": "oAuthProvider", + "groupId": "group-private-users", + "permission": ["view"] } ] } diff --git a/api/src/dto/OAuthProviderDto.ts b/api/src/dto/OAuthProviderDto.ts new file mode 100644 index 0000000000..9e1e4f67cc --- /dev/null +++ b/api/src/dto/OAuthProviderDto.ts @@ -0,0 +1,143 @@ +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; +import { Expose } from "class-transformer"; +import { _contentBaseDto } from "./_contentBaseDto"; +import { Uuid } from "../enums"; +import { ImageDto } from "./ImageDto"; + +/** Condition for group assignment (AND semantics). */ +export type GroupAssignmentCondition = + | { type: "always" } + | { type: "authenticated" } + | { type: "claimEquals"; claimPath: string; value: string } + | { type: "claimIn"; claimPath: string; values: string[] }; + +export type GroupAssignment = { + groupId: string; + conditions: GroupAssignmentCondition[]; +}; + +export type UserFieldMappings = { + userId?: string; + email?: string; + name?: string; +}; + +/** + * OAuthProviderDto represents an OAuth provider configuration. + * Currently supports Auth0, with a clean structure for future providers. + * Credentials are stored encrypted via credential_id reference. + */ +export class OAuthProviderDto extends _contentBaseDto { + @IsNotEmpty() + @IsString() + @Expose() + label: string; + + @IsNotEmpty() + @IsString() + @Expose() + providerType: "auth0"; + + @IsOptional() + @IsString() + @Expose() + textColor?: string; + + @IsOptional() + @IsString() + @Expose() + backgroundColor?: string; + + @IsOptional() + @IsString() + @Expose() + clientId?: string; + + @IsOptional() + @IsString() + @Expose() + domain?: string; + + @IsOptional() + @IsString() + @Expose() + audience?: string; + + @IsOptional() + @IsString() + @Expose() + icon?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + @Expose() + /** Icon opacity 0–1; applied when displaying the icon. */ + iconOpacity?: number; + + @IsOptional() + @Expose() + /** + * Image data for the provider icon. + */ + imageData?: ImageDto; + + @IsOptional() + @IsString() + @Expose() + /** + * Bucket ID where images are stored. + */ + imageBucketId?: Uuid; + + @IsOptional() + @IsString() + @Expose() + /** + * Custom claim namespace configured in the Auth0 tenant's Actions/Rules. + * Used to extract user details (userId, email, name) from the JWT payload. + * e.g. "https://your-tenant.com/metadata" + */ + claimNamespace?: string; + + @IsOptional() + @Expose() + /** + * Generic claim-to-system-concept mappings. + * Each entry maps a JWT claim field to a system target. + * e.g. [{ claim: "groups", target: "groups" }] + * or [{ claim: "hasMembership", target: "groups" }] + */ + claimMappings?: Array<{ claim: string; target: string }>; + + @IsOptional() + @Expose() + /** + * Field names inside claimNamespace for userId, email, name. + */ + userFieldMappings?: UserFieldMappings; + + @IsOptional() + @Expose() + /** + * Assign groups when all conditions pass (AND). Invalid groupIds are no-ops. + */ + groupAssignments?: GroupAssignment[]; + + @IsOptional() + @IsBoolean() + @Expose() + /** + * When true, used for no-JWT (guest) and excluded from domain match when JWT present. + */ + isGuestProvider?: boolean; +} diff --git a/api/src/dto/UserDto.ts b/api/src/dto/UserDto.ts index 4ad6f0c146..37a4f2093d 100644 --- a/api/src/dto/UserDto.ts +++ b/api/src/dto/UserDto.ts @@ -17,13 +17,22 @@ export class UserDto extends _contentBaseDto { name: string; /** - * External user ID for mapping to external systems + * External user ID within the OAuth provider (e.g. Auth0 sub) */ @IsOptional() @IsString() @Expose() userId?: string; + /** + * The _id of the OAuthProvider document that authenticated this user. + * Combined with userId, forms a unique compound key for identity resolution. + */ + @IsOptional() + @IsString() + @Expose() + oAuthProviderId?: string; + @IsOptional() @IsNumber() @Expose() diff --git a/api/src/endpoints/changeRequest.controller.ts b/api/src/endpoints/changeRequest.controller.ts index f8fb97e955..8cbf8cd8d9 100644 --- a/api/src/endpoints/changeRequest.controller.ts +++ b/api/src/endpoints/changeRequest.controller.ts @@ -6,6 +6,7 @@ import { ChangeRequestService } from "./changeRequest.service"; import { FastifyRequest } from "fastify"; import { removeDangerousKeys } from "../util/removeDangerousKeys"; import { patchFileData } from "../util/patchFileData"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; @Controller("changerequest") export class ChangeRequestController { @@ -16,9 +17,8 @@ export class ChangeRequestController { @UsePipes() async handleChangeRequest( @Req() request: FastifyRequest, - @Headers("Authorization") authHeader: string, ) { - const token = authHeader?.replace("Bearer ", "") ?? ""; + const identity = (request as any).user as ResolvedIdentity; // Check if the request is multipart const isMultipartRequest = @@ -112,7 +112,7 @@ export class ChangeRequestController { changeRequest.apiVersion = apiVersion; } - const result = await this.changeRequestService.changeRequest(changeRequest, token); + const result = await this.changeRequestService.changeRequest(changeRequest, identity); return result; } @@ -121,7 +121,7 @@ export class ChangeRequestController { await validateApiVersion(body.apiVersion); // Clean prototype pollution from the body before processing const cleanedBody = removeDangerousKeys(body); - const result = await this.changeRequestService.changeRequest(cleanedBody, token); + const result = await this.changeRequestService.changeRequest(cleanedBody, identity); return result; } diff --git a/api/src/endpoints/changeRequest.service.ts b/api/src/endpoints/changeRequest.service.ts index e78335d740..ce701518b4 100644 --- a/api/src/endpoints/changeRequest.service.ts +++ b/api/src/endpoints/changeRequest.service.ts @@ -1,45 +1,32 @@ import { ChangeReqDto } from "../dto/ChangeReqDto"; -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { DbService } from "../db/db.service"; import { AckStatus, AclPermission, DocType, Uuid } from "../enums"; -import { WINSTON_MODULE_PROVIDER } from "nest-winston"; -import { Logger } from "winston"; -import { processJwt } from "../jwt/processJwt"; -import configuration, { Configuration } from "../configuration"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; import { processChangeRequest } from "../changeRequests/processChangeRequest"; import { ChangeReqAckDto } from "../dto/ChangeReqAckDto"; import { PermissionSystem } from "../permissions/permissions.service"; @Injectable() export class ChangeRequestService { - private readonly test: any = []; - private permissionMap: any; - private config: Configuration; + constructor(private db: DbService) {} - constructor( - @Inject(WINSTON_MODULE_PROVIDER) - private readonly logger: Logger, - private db: DbService, - ) { - // Create config object with environmental variables - this.config = configuration(); - } - - async changeRequest(changeRequest: ChangeReqDto, token: string): Promise { - const userDetails = await processJwt(token, this.db, this.logger); + async changeRequest(changeRequest: ChangeReqDto, identity: ResolvedIdentity): Promise { + const userId = identity.user.userId || identity.user._id; + const groups = identity.groupIds as Uuid[]; // Process change request return await processChangeRequest( - userDetails.userId, + userId, changeRequest, - userDetails.groups, + groups, this.db, ) .then(async (result) => { const ack = await this.upsertDocAck( changeRequest, AckStatus.Accepted, - userDetails.groups, + groups, ); // Add warnings to the acknowledgment if any @@ -53,7 +40,7 @@ export class ChangeRequestService { return await this.upsertDocAck( changeRequest, AckStatus.Rejected, - userDetails.groups, + groups, err.message, ); }); diff --git a/api/src/endpoints/query.controller.ts b/api/src/endpoints/query.controller.ts index 9bca56a12b..ed6524fa90 100644 --- a/api/src/endpoints/query.controller.ts +++ b/api/src/endpoints/query.controller.ts @@ -1,21 +1,22 @@ import { Controller, - Headers, Post, Body, BadRequestException, Inject, HttpCode, + UseGuards, + Req, } from "@nestjs/common"; -// import { validateApiVersion } from "../validation/apiVersion"; import { QueryService } from "./query.service"; import { MongoQueryDto } from "../dto/MongoQueryDto"; -// import { FindReqDto } from "../dto/FindReqDto"; -// import { plainToClass } from "class-transformer"; import validateMongoQuery from "../db/MongoQueryTemplates/validateMongoQuery"; import { ConfigService } from "@nestjs/config"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; import { Logger } from "winston"; +import { AuthGuard } from "../auth/auth.guard"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; +import { FastifyRequest } from "fastify"; /** Endpoint supporting MongoDB like queries (Mango Query) */ @Controller("query") @@ -29,9 +30,8 @@ export class QueryController { @Post() @HttpCode(200) // override the default 201 created status code to enable gzip compression by downstream reverse proxy servers - async processPostReq(@Body() body: any, @Headers("Authorization") auth: string): Promise { - // TODO: add api version validation - + @UseGuards(AuthGuard) + async processPostReq(@Body() body: any, @Req() request: FastifyRequest): Promise { const bypassValidation = this.configService.get("validation.bypassTemplateValidation") || false; @@ -43,9 +43,7 @@ export class QueryController { } delete body.identifier; - return this.queryService.query( - body as MongoQueryDto, - auth !== undefined ? auth.replace("Bearer ", "") : "", - ); + const identity = (request as any).user as ResolvedIdentity; + return this.queryService.query(body as MongoQueryDto, identity); } } diff --git a/api/src/endpoints/query.service.ts b/api/src/endpoints/query.service.ts index e8c0b9efd6..2ab2aad5a6 100644 --- a/api/src/endpoints/query.service.ts +++ b/api/src/endpoints/query.service.ts @@ -4,7 +4,7 @@ import { AclPermission, DocType, PublishStatus, Uuid } from "../enums"; import { PermissionSystem } from "../permissions/permissions.service"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; import { Logger } from "winston"; -import { processJwt } from "../jwt/processJwt"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; import { MongoQueryDto } from "../dto/MongoQueryDto"; import { MongoComparisonCriteria, MongoSelectorDto } from "../dto/MongoSelectorDto"; import { LanguageDto } from "../dto/LanguageDto"; @@ -46,7 +46,7 @@ export class QueryService { }); } - async query(query: MongoQueryDto, authToken: string): Promise { + async query(query: MongoQueryDto, identity: ResolvedIdentity): Promise { const now = Date.now(); // Expand the selector to ensure it is in the correct format, allowing injection of additional conditions like permission checks. @@ -94,11 +94,11 @@ export class QueryService { } // Get user accessible groups - const userDetails = await processJwt(authToken, this.db, this.logger); + const accessMap = PermissionSystem.getAccessMap(identity.groupIds); // TODO: Get view permissions based CMS access if CMS view permissions are set (future) const userViewGroups = PermissionSystem.accessMapToGroups( - userDetails.accessMap, + accessMap, AclPermission.View, [...permissionCheckTypes], ); diff --git a/api/src/endpoints/search.controller.ts b/api/src/endpoints/search.controller.ts index 28e1aff6b3..b97aa41fc5 100644 --- a/api/src/endpoints/search.controller.ts +++ b/api/src/endpoints/search.controller.ts @@ -1,24 +1,26 @@ -import { Controller, Headers, Get } from "@nestjs/common"; +import { Controller, Headers, Get, UseGuards, Req } from "@nestjs/common"; import { SearchReqDto } from "../dto/SearchReqDto"; import { SearchService } from "./search.service"; import { xQuery } from "../validation/x-query"; import { validateApiVersion } from "../validation/apiVersion"; +import { AuthGuard } from "../auth/auth.guard"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; +import { FastifyRequest } from "fastify"; @Controller("search") export class SearchController { constructor(private readonly searchService: SearchService) {} @Get() + @UseGuards(AuthGuard) async getDocs( @Headers("X-Query") query: string, - @Headers("Authorization") auth: string, + @Req() request: FastifyRequest, ): Promise { const queryObj = xQuery(query, SearchReqDto); await validateApiVersion(queryObj.apiVersion); // validate API version - return this.searchService.processReq( - queryObj, - auth !== undefined ? auth.replace("Bearer ", "") : "", - ); + const identity = (request as any).user as ResolvedIdentity; + return this.searchService.processReq(queryObj, identity); } } diff --git a/api/src/endpoints/search.service.ts b/api/src/endpoints/search.service.ts index 1646fa63cd..749d0232f4 100644 --- a/api/src/endpoints/search.service.ts +++ b/api/src/endpoints/search.service.ts @@ -4,7 +4,7 @@ import { AclPermission, DocType } from "../enums"; import { PermissionSystem } from "../permissions/permissions.service"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; import { Logger } from "winston"; -import { processJwt } from "../jwt/processJwt"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; import { SearchReqDto } from "../dto/SearchReqDto"; import { SearchOptions } from "../db/db.searchFunctions"; @@ -21,8 +21,8 @@ export class SearchService { * @param req - api request * @returns */ - async processReq(query: SearchReqDto, token: string): Promise { - const userDetails = await processJwt(token, this.db, this.logger); + async processReq(query: SearchReqDto, identity: ResolvedIdentity): Promise { + const accessMap = PermissionSystem.getAccessMap(identity.groupIds); // Validate request if (!query.slug && (!query.types || query.types.length < 1)) { @@ -52,7 +52,7 @@ export class SearchService { // Get user accessible groups const userViewGroups = PermissionSystem.accessMapToGroups( - userDetails.accessMap, + accessMap, AclPermission.View, [...query.types, DocType.Language], ); @@ -93,7 +93,7 @@ export class SearchService { } }) .catch((err) => { - this.logger.error(`Error getting data for client: ${userDetails.userId}`, err); + this.logger.error(`Error getting data for client: ${identity.user.userId}`, err); }); return _res; } diff --git a/api/src/endpoints/storageStatus.controller.ts b/api/src/endpoints/storageStatus.controller.ts index c257a989f5..b0fe34947a 100644 --- a/api/src/endpoints/storageStatus.controller.ts +++ b/api/src/endpoints/storageStatus.controller.ts @@ -3,7 +3,7 @@ import { Get, Query, UseGuards, - Headers, + Req, HttpException, HttpStatus, } from "@nestjs/common"; @@ -11,9 +11,10 @@ import { AuthGuard } from "../auth/auth.guard"; import { S3Service } from "../s3/s3.service"; import { DbService } from "../db/db.service"; import { validateApiVersion } from "../validation/apiVersion"; -import { processJwt } from "../jwt/processJwt"; +import { ResolvedIdentity } from "../auth/auth-identity.service"; import { PermissionSystem } from "../permissions/permissions.service"; import { AclPermission, DocType } from "../enums"; +import { FastifyRequest } from "fastify"; export type StorageStatusResponseDto = { status: "connected" | "unreachable" | "unauthorized" | "not-found" | "no-credentials"; @@ -29,17 +30,11 @@ export class StorageStatusController { async getStorageStatus( @Query("bucketId") bucketId: string, @Query("apiVersion") apiVersion: string, - @Headers("Authorization") authHeader: string, + @Req() request: FastifyRequest, ): Promise { await validateApiVersion(apiVersion); - // Extract and process JWT token - const token = authHeader?.replace("Bearer ", "") ?? ""; - if (!token) { - throw new HttpException("Authorization token required", HttpStatus.UNAUTHORIZED); - } - - const userDetails = await processJwt(token, this.dbService, undefined); + const identity = (request as any).user as ResolvedIdentity; // Validate bucketId parameter if (!bucketId) { @@ -64,7 +59,7 @@ export class StorageStatusController { bucket.memberOf, DocType.Storage, AclPermission.View, - userDetails.groups, + identity.groupIds, ); if (!hasPermission) { diff --git a/api/src/enums.ts b/api/src/enums.ts index 3e95ba99e7..030083d4fb 100644 --- a/api/src/enums.ts +++ b/api/src/enums.ts @@ -22,6 +22,7 @@ export enum DocType { DeleteCmd = "deleteCmd", Storage = "storage", Crypto = "crypto", + OAuthProvider = "oAuthProvider", } /** diff --git a/api/src/jwt/processJwt.ts b/api/src/jwt/processJwt.ts index b9c9164def..fc0a3bdd83 100644 --- a/api/src/jwt/processJwt.ts +++ b/api/src/jwt/processJwt.ts @@ -77,7 +77,10 @@ export async function processJwt( const jwtMapEnv = configuration().auth.jwtMappings; if (!jwtMapEnv) { logger?.error(`JWT_MAPPING environment variable is not set`); - return { groups: [] }; + return { + groups: [], + accessMap: PermissionSystem.getAccessMap([]), + }; } jwtMap = parseJwtMap(jwtMapEnv, logger); } @@ -111,14 +114,17 @@ export async function processJwt( } } catch (err) { logger?.error(`Unable to get JWT mappings`, err); - return { groups: [] }; + return { + groups: [], + accessMap: PermissionSystem.getAccessMap([]), + }; } // If userId is set, get the user details from the database using the userId if (userId) { userId = userId.toString(); } - + const userDocs = (await db.getUserByIdOrEmail(email, userId)).docs as UserDto[]; // Update user details in the database if either userId or email is set diff --git a/api/src/permissions/permissions.service.ts b/api/src/permissions/permissions.service.ts index 8a0a3ded86..76fb2b6c74 100644 --- a/api/src/permissions/permissions.service.ts +++ b/api/src/permissions/permissions.service.ts @@ -243,6 +243,9 @@ export class PermissionSystem extends EventEmitter { docTypes: DocType[], ): Map { const groups = new Map(); + if (!accessMap || typeof accessMap !== "object") { + return groups; + } Object.keys(accessMap).forEach((groupId: Uuid) => { Object.keys(accessMap[groupId]) .filter((d: DocType) => docTypes.includes(d)) diff --git a/api/src/socketio.ts b/api/src/socketio.ts index ddd6aa15b9..794c983577 100644 --- a/api/src/socketio.ts +++ b/api/src/socketio.ts @@ -14,10 +14,13 @@ import { Socket, Server } from "socket.io"; import { ChangeReqDto } from "./dto/ChangeReqDto"; import { AccessMap } from "./permissions/permissions.service"; import configuration, { Configuration } from "./configuration"; -import { JwtUserDetails, processJwt } from "./jwt/processJwt"; +import { AuthIdentityService, ResolvedIdentity } from "./auth/auth-identity.service"; +import { OAuthProviderDto } from "./dto/OAuthProviderDto"; import { S3Service } from "./s3/s3.service"; import { WINSTON_MODULE_PROVIDER } from "nest-winston"; import { Logger } from "winston"; +import * as jwtLib from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; /** * Data request from client type definition @@ -68,11 +71,14 @@ interface ReceiveEvents { // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface InterServerEvents {} +/** Guest identity used when no auth token is provided */ +const GUEST_GROUP_ID = "group-public-users"; + /** * Socket.io client socket.data type definition */ interface SocketData { - userDetails: JwtUserDetails; + identity: ResolvedIdentity; } type ClientSocket = Socket; @@ -92,25 +98,49 @@ export class Socketio implements OnGatewayInit { private readonly logger: Logger, private db: DbService, private s3: S3Service, + private identity: AuthIdentityService, ) {} afterInit(server: Server) { // Handle authentication server.use(async (socket, next) => { - // Get automatically assigned group access - const userDetails = await processJwt(socket.handshake.auth.token, this.db, this.logger); + const token = socket.handshake.auth.token as string | undefined; + + // No token → guest access + if (!token) { + socket.data.identity = { + user: { _id: "guest", type: DocType.User, email: "", name: "Guest", memberOf: [] } as any, + groupIds: [GUEST_GROUP_ID], + }; + return next(); + } + + try { + // Decode without verification to extract the issuer domain + const decoded = jwtLib.decode(token) as jwtLib.JwtPayload | null; + if (!decoded?.iss) throw new Error("Missing iss claim"); + + const domain = new URL(decoded.iss).hostname; - if (socket.handshake.auth.token && !userDetails.jwtPayload) { - // Assume that the user's token is expired. - // Prompt the user to re-authenticate when an invalid token is provided. + // Find the OAuthProvider by domain + const providerResult = await this.db.executeFindQuery({ + selector: { type: DocType.OAuthProvider, domain }, + limit: 1, + }); + const provider = providerResult.docs?.[0] as OAuthProviderDto | undefined; + if (!provider) throw new Error(`No OAuthProvider found for domain: ${domain}`); + + // Verify the token using the provider's JWKS + const payload = await this.verifyToken(token, provider); + + // Resolve full identity + socket.data.identity = await this.identity.resolveIdentity(payload, provider); + next(); + } catch (err) { + this.logger.error("Socket authentication failed", err); socket.emit("apiAuthFailed"); - // Disconnect the client to prevent further communication. socket.disconnect(true); - return; } - - socket.data.userDetails = userDetails; - next(); }); // Create config object with environmental variables @@ -160,6 +190,28 @@ export class Socketio implements OnGatewayInit { }); } + private verifyToken(token: string, provider: OAuthProviderDto): Promise { + const jwksUri = `https://${provider.domain}/.well-known/jwks.json`; + const client = new JwksClient({ jwksUri }); + + const getKey: jwtLib.GetPublicKeyOrSecret = (header, callback) => { + client.getSigningKey(header.kid, (err, key) => { + if (err) return callback(err); + callback(null, key.getPublicKey()); + }); + }; + + const options: jwtLib.VerifyOptions = {}; + if (provider.audience) options.audience = provider.audience; + + return new Promise((resolve, reject) => { + jwtLib.verify(token, getKey, options, (err, decoded) => { + if (err) return reject(err); + resolve(decoded as jwtLib.JwtPayload); + }); + }); + } + /** * Join client to socket groups, to receive live updates * @param reqData @@ -170,11 +222,14 @@ export class Socketio implements OnGatewayInit { @MessageBody() reqData: ClientDataReq, @ConnectedSocket() socket: ClientSocket, ) { + // Compute access map from resolved group IDs + const accessMap = PermissionSystem.getAccessMap(socket.data.identity.groupIds); + // Send client configuration data and access map const clientConfig = { maxUploadFileSize: this.config.socketIo.maxHttpBufferSize, maxMediaUploadFileSize: this.config.socketIo.maxMediaUploadFileSize || 0, - accessMap: socket.data.userDetails.accessMap, + accessMap, } as ClientConfig; socket.emit("clientConfig", clientConfig); @@ -186,7 +241,7 @@ export class Socketio implements OnGatewayInit { // Get user accessible groups const userViewGroups = PermissionSystem.accessMapToGroups( - socket.data.userDetails.accessMap, + accessMap, AclPermission.View, docTypes, ); diff --git a/app/src/App.vue b/app/src/App.vue index 13a0aeba6a..0acc316cb1 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -8,6 +8,7 @@ import { ExclamationCircleIcon, SignalSlashIcon } from "@heroicons/vue/20/solid" import * as Sentry from "@sentry/vue"; import { useRouter } from "vue-router"; import PrivacyPolicyModal from "@/components/navigation/PrivacyPolicyModal.vue"; +import ProviderSelectionModal from "@/components/navigation/ProviderSelectionModal.vue"; import AudioPlayer from "@/components/content/AudioPlayer.vue"; import MobileMenu from "@/components/navigation/MobileMenu.vue"; import { useAuthWithPrivacyPolicy } from "@/composables/useAuthWithPrivacyPolicy"; @@ -134,4 +135,6 @@ onErrorCaptured((err) => { + + diff --git a/app/src/auth.ts b/app/src/auth.ts index 0ca4cad800..6624a9363b 100644 --- a/app/src/auth.ts +++ b/app/src/auth.ts @@ -1,7 +1,56 @@ import { Auth0Plugin, createAuth0 } from "@auth0/auth0-vue"; -import { type App, watch } from "vue"; +import { createAuth0Client } from "@auth0/auth0-spa-js"; +import { type App, ref, watch } from "vue"; import type { Router } from "vue-router"; import * as Sentry from "@sentry/vue"; +import type { OAuthProviderPublicDto } from "luminary-shared"; +import { useAuthProviderStore } from "./stores/authProvider"; + +/** + * Controls visibility of the provider selection modal. + */ +export const showProviderSelectionModal = ref(false); + +/** + * Clear all Auth0 tokens from localStorage so the next login prompts fresh credentials. + */ +export function clearAuth0Cache() { + Object.keys(localStorage) + .filter((k) => k.startsWith("@@auth0spajs@@") || k.startsWith("a0.spajs")) + .forEach((k) => localStorage.removeItem(k)); +} + +/** + * Log in using a specific OAuthProvider. Creates a provider-scoped Auth0 client + * and redirects the user to Auth0 for authentication. + */ +export async function loginWithProvider( + provider: OAuthProviderPublicDto, + options: { prompt?: "none" | "login" | "consent" | "select_account" } = {}, +) { + if (!provider.domain || !provider.clientId) { + console.error("Provider is missing domain or clientId", provider._id); + return; + } + + const store = useAuthProviderStore(); + store.setProvider(provider); + + const client = await createAuth0Client({ + domain: provider.domain, + clientId: provider.clientId, + useRefreshTokens: true, + cacheLocation: "localstorage", + authorizationParams: { + audience: provider.audience, + scope: "openid profile email offline_access", + redirect_uri: window.location.origin, + ...(options.prompt ? { prompt: options.prompt } : {}), + }, + }); + + await client.loginWithRedirect(); +} export type AuthPlugin = Auth0Plugin & { logout: (retrying?: boolean) => Promise; diff --git a/app/src/components/navigation/ProviderSelectionModal.vue b/app/src/components/navigation/ProviderSelectionModal.vue new file mode 100644 index 0000000000..5ab495a9af --- /dev/null +++ b/app/src/components/navigation/ProviderSelectionModal.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/src/composables/useAuthWithPrivacyPolicy.ts b/app/src/composables/useAuthWithPrivacyPolicy.ts index 35c40445d7..2dda307f29 100644 --- a/app/src/composables/useAuthWithPrivacyPolicy.ts +++ b/app/src/composables/useAuthWithPrivacyPolicy.ts @@ -1,6 +1,7 @@ import { useAuth0 } from "@auth0/auth0-vue"; import { computed, ref } from "vue"; import { userPreferencesAsRef } from "@/globalConfig"; +import { showProviderSelectionModal } from "@/auth"; // Global state for privacy policy modal export const showPrivacyPolicyModal = ref(false); @@ -30,7 +31,7 @@ export function useAuthWithPrivacyPolicy() { }; } - const { isAuthenticated, user, loginWithRedirect: originalLoginWithRedirect, logout } = auth0; + const { isAuthenticated, user, logout } = auth0; // Check if privacy policy is accepted const isPrivacyPolicyAccepted = computed(() => { @@ -40,11 +41,11 @@ export function useAuthWithPrivacyPolicy() { // Enhanced login function that checks privacy policy first const loginWithRedirect = () => { if (isPrivacyPolicyAccepted.value) { - // Privacy policy is already accepted, proceed with login - originalLoginWithRedirect(); + // Privacy policy is already accepted, show provider selection + showProviderSelectionModal.value = true; } else { // Privacy policy not accepted, show modal first - pendingLoginAction = () => originalLoginWithRedirect(); + pendingLoginAction = () => { showProviderSelectionModal.value = true; }; hasPendingLogin.value = true; showPrivacyPolicyModal.value = true; } diff --git a/app/src/main.ts b/app/src/main.ts index 63cb607751..742ae11550 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -1,5 +1,6 @@ import "./assets/main.css"; import { createApp } from "vue"; +import { selectedProviderId } from "./stores/authProvider"; import { createPinia } from "pinia"; import App from "./App.vue"; import router from "./router"; @@ -12,6 +13,16 @@ import { initAppTitle, initI18n } from "./i18n"; import { initAnalytics } from "./analytics"; import { initSync, initLanguageSync } from "./sync"; +// Inject X-Query (provider ID) header on every fetch request to the API +const _nativeFetch = window.fetch.bind(window); +window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (selectedProviderId.value && url.startsWith(apiUrl)) { + init = { ...init, headers: { ...((init?.headers as Record) ?? {}), "X-Query": selectedProviderId.value } }; + } + return _nativeFetch(input, init); +}; + export const app = createApp(App); if (import.meta.env.VITE_FAV_ICON) { @@ -46,6 +57,7 @@ async function Startup() { token, appLanguageIdsAsRef, syncList: [ + { type: DocType.OAuthProvider, syncPriority: 1 }, { type: DocType.Tag, contentOnly: true, syncPriority: 2 }, { type: DocType.Post, contentOnly: true, syncPriority: 2 }, { diff --git a/app/src/stores/authProvider.ts b/app/src/stores/authProvider.ts new file mode 100644 index 0000000000..571a2e76a2 --- /dev/null +++ b/app/src/stores/authProvider.ts @@ -0,0 +1,57 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { OAuthProviderPublicDto } from "luminary-shared"; + +const PROVIDER_ID_KEY = "selectedProviderId"; + +/** + * Module-level ref so the fetch interceptor can read the provider ID + * without needing a Pinia context. Initialized from localStorage so it + * survives the Auth0 redirect/page-reload cycle. + */ +export const selectedProviderId = ref(localStorage.getItem(PROVIDER_ID_KEY)); + +export const useAuthProviderStore = defineStore("authProvider", () => { + const selectedProvider = ref(null); + + function setProvider(provider: OAuthProviderPublicDto) { + selectedProviderId.value = provider._id; + selectedProvider.value = provider; + localStorage.setItem(PROVIDER_ID_KEY, provider._id); + } + + function clearProvider() { + selectedProviderId.value = null; + selectedProvider.value = null; + localStorage.removeItem(PROVIDER_ID_KEY); + } + + /** + * Clear all Auth0 tokens from localStorage. + * Auth0 SPA JS stores entries with the prefix @@auth0spajs@@ and a0.spajs. + */ + function clearAuth0Tokens() { + const keysToRemove = Object.keys(localStorage).filter( + (k) => k.startsWith("@@auth0spajs@@") || k.startsWith("a0.spajs"), + ); + keysToRemove.forEach((k) => localStorage.removeItem(k)); + } + + /** + * Global logout: clears the in-memory provider, wipes stored Auth0 tokens, + * and reloads the page so the app reconnects anonymously (public access). + */ + function globalLogout() { + clearProvider(); + clearAuth0Tokens(); + // Reload so the socket reconnects without a token, restoring public access + window.location.href = window.location.origin; + } + + return { + selectedProvider, + setProvider, + clearProvider, + globalLogout, + }; +}); diff --git a/app/src/sync.ts b/app/src/sync.ts index 8fe6e2bfbc..a16bbf37de 100644 --- a/app/src/sync.ts +++ b/app/src/sync.ts @@ -153,6 +153,19 @@ export function initSync() { Sentry?.captureException(err); }); } + + // Sync OAuth providers + if (access[DocType.OAuthProvider] && access[DocType.OAuthProvider].length) { + sync({ + type: DocType.OAuthProvider, + memberOf: access[DocType.OAuthProvider], + limit: 100, + cms: false, + }).catch((err) => { + console.error("Error during OAuth provider sync:", err); + Sentry?.captureException(err); + }); + } }, { immediate: true }, ); diff --git a/cms/src/App.vue b/cms/src/App.vue index 6d2bf614e9..4e4d09f172 100644 --- a/cms/src/App.vue +++ b/cms/src/App.vue @@ -9,6 +9,7 @@ import router from "./router"; import MobileSideBar from "@/components/navigation/MobileSideBar.vue"; import SideBar from "@/components/navigation/SideBar.vue"; import { isAuthBypassed } from "@/auth"; +import ProviderSelectionModal from "@/components/navigation/ProviderSelectionModal.vue"; // In auth bypass mode, always treat as authenticated const auth0 = isAuthBypassed ? null : useAuth0(); @@ -61,5 +62,6 @@ const routeKey = computed(() => { + diff --git a/cms/src/auth.ts b/cms/src/auth.ts index 90297397e3..b777ac2028 100644 --- a/cms/src/auth.ts +++ b/cms/src/auth.ts @@ -1,7 +1,78 @@ import { Auth0Plugin, createAuth0 } from "@auth0/auth0-vue"; +import { createAuth0Client } from "@auth0/auth0-spa-js"; import { type App, ref, watch } from "vue"; import type { Router } from "vue-router"; import * as Sentry from "@sentry/vue"; +import type { OAuthProviderPublicDto } from "luminary-shared"; +import { useAuthProviderStore } from "./stores/authProvider"; + +/** + * localStorage key for persisting provider credentials across the Auth0 redirect cycle. + * Kept until logout so token refresh works on subsequent loads. + */ +const SELECTED_PROVIDER_CREDS_KEY = "selectedProviderCredentials"; + +type PersistedProviderCredentials = { + domain: string; + clientId: string; + audience?: string; +}; + +/** + * Controls visibility of the provider selection modal. + */ +export const showProviderSelectionModal = ref(false); + +/** + * Clear all Auth0 tokens from localStorage so the next login prompts fresh credentials. + */ +export function clearAuth0Cache() { + Object.keys(localStorage) + .filter((k) => k.startsWith("@@auth0spajs@@") || k.startsWith("a0.spajs")) + .forEach((k) => localStorage.removeItem(k)); +} + +/** + * Log in using a specific OAuthProvider. Creates a provider-scoped Auth0 client + * and redirects the user to Auth0 for authentication. + */ +export async function loginWithProvider( + provider: OAuthProviderPublicDto, + options: { prompt?: "none" | "login" | "consent" | "select_account" } = {}, +) { + if (!provider.domain || !provider.clientId) { + console.error("Provider is missing domain or clientId", provider._id); + return; + } + + const store = useAuthProviderStore(); + store.setProvider(provider); + + // Persist credentials so we can handle the Auth0 redirect callback after page reload + localStorage.setItem( + SELECTED_PROVIDER_CREDS_KEY, + JSON.stringify({ + domain: provider.domain, + clientId: provider.clientId, + audience: provider.audience, + } satisfies PersistedProviderCredentials), + ); + + const client = await createAuth0Client({ + domain: provider.domain, + clientId: provider.clientId, + useRefreshTokens: true, + cacheLocation: "localstorage", + authorizationParams: { + audience: provider.audience, + scope: "openid profile email offline_access", + redirect_uri: window.location.origin, + ...(options.prompt ? { prompt: options.prompt } : {}), + }, + }); + + await client.loginWithRedirect(); +} export type AuthPlugin = Auth0Plugin & { logout: (retrying?: boolean) => Promise; @@ -12,6 +83,32 @@ export type AuthPlugin = Auth0Plugin & { */ export const isAuthBypassed = import.meta.env.VITE_AUTH_BYPASS === "true"; +/** + * Stub auth plugin used when Auth0 env vars are not configured. + * Always reports unauthenticated; the provider selection modal handles login. + */ +function createNoAuthPlugin(): AuthPlugin { + return { + isAuthenticated: ref(false), + isLoading: ref(false), + user: ref(null), + idTokenClaims: ref(null), + error: ref(null), + loginWithRedirect: async () => {}, + loginWithPopup: async () => {}, + logout: async () => {}, + getAccessTokenSilently: async () => { + throw new Error("No Auth0 domain configured"); + }, + getAccessTokenWithPopup: async () => { + throw new Error("No Auth0 domain configured"); + }, + checkSession: async () => {}, + handleRedirectCallback: async () => ({ appState: {} }), + install: () => {}, + } as unknown as AuthPlugin; +} + /** * Mock auth plugin for bypass mode */ @@ -53,6 +150,73 @@ async function setupAuth(app: App, router: Router) { return mockAuth; } + // If Auth0 env vars are not configured, use the provider selection modal for login + if (!import.meta.env.VITE_AUTH0_DOMAIN || !import.meta.env.VITE_AUTH0_CLIENT_ID) { + console.warn( + "Auth0 env vars (VITE_AUTH0_DOMAIN / VITE_AUTH0_CLIENT_ID) are not set — provider selection modal will handle authentication", + ); + + const storedCredsJson = localStorage.getItem(SELECTED_PROVIDER_CREDS_KEY); + if (storedCredsJson) { + // We have persisted provider credentials — use them to create a real Auth0 plugin. + // This handles both the post-redirect callback (code+state in URL) and subsequent + // loads where we need silent token refresh from localStorage. + const creds: PersistedProviderCredentials = JSON.parse(storedCredsJson); + const web_origin = window.location.origin; + + const oauth = createAuth0( + { + domain: creds.domain, + clientId: creds.clientId, + useRefreshTokens: true, + useRefreshTokensFallback: true, + cacheLocation: "localstorage", + authorizationParams: { + audience: creds.audience, + scope: "openid profile email offline_access", + redirect_uri: web_origin, + }, + }, + { skipRedirectCallback: true }, + ); + + app.use(oauth); + + // Handle the redirect callback if Auth0 returned a code+state + const url = new URL(location.href); + if (url.searchParams.has("code") && url.searchParams.has("state")) { + if (url.searchParams.has("error")) { + console.error("Auth0 redirect error:", url.searchParams.get("error")); + } else { + await oauth.handleRedirectCallback(location.href).catch((err) => { + console.error("Failed to handle Auth0 redirect callback", err); + }); + } + router.push("/"); + } + + if (oauth.isLoading.value) { + await new Promise((resolve) => { + watch(oauth.isLoading, () => resolve(void 0), { once: true }); + }); + } + + const _Logout = oauth.logout; + (oauth as AuthPlugin).logout = (retrying = false) => { + localStorage.removeItem(SELECTED_PROVIDER_CREDS_KEY); + let returnTo = web_origin; + if (!retrying) returnTo += "?loggedOut"; + return _Logout({ logoutParams: { returnTo } }); + }; + + return oauth as AuthPlugin; + } + + const noAuth = createNoAuthPlugin(); + app.config.globalProperties.$auth = noAuth; + return noAuth; + } + app.config.globalProperties.$auth = null; // Clear existing auth const web_origin = window.location.origin; @@ -174,7 +338,8 @@ async function loginRedirect(oauth: AuthPlugin) { } /** - * Get the user's auth token. Redirect to login if necessary. + * Get the user's auth token. Shows the provider selection modal if the token + * cannot be obtained silently. */ async function getToken(oauth: AuthPlugin) { const { isAuthenticated, getAccessTokenSilently } = oauth; @@ -184,7 +349,7 @@ async function getToken(oauth: AuthPlugin) { return await getAccessTokenSilently(); } catch (err) { Sentry.captureException(err); - await loginRedirect(oauth); + showProviderSelectionModal.value = true; } } } diff --git a/cms/src/components/navigation/ProviderSelectionModal.vue b/cms/src/components/navigation/ProviderSelectionModal.vue new file mode 100644 index 0000000000..58eeed641e --- /dev/null +++ b/cms/src/components/navigation/ProviderSelectionModal.vue @@ -0,0 +1,93 @@ + + + diff --git a/cms/src/components/oAuthProvider/OAuthProviderDisplayCard.vue b/cms/src/components/oAuthProvider/OAuthProviderDisplayCard.vue new file mode 100644 index 0000000000..c63838e7cd --- /dev/null +++ b/cms/src/components/oAuthProvider/OAuthProviderDisplayCard.vue @@ -0,0 +1,184 @@ + + + diff --git a/cms/src/components/oAuthProvider/OAuthProviderFormModal.spec.ts b/cms/src/components/oAuthProvider/OAuthProviderFormModal.spec.ts new file mode 100644 index 0000000000..c8525e572a --- /dev/null +++ b/cms/src/components/oAuthProvider/OAuthProviderFormModal.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import OAuthProviderFormModal from "./OAuthProviderFormModal.vue"; +import LInput from "../forms/LInput.vue"; +import { DocType, type OAuthProviderDto, type GroupDto } from "luminary-shared"; +import * as mockData from "@/tests/mockdata"; + +vi.mock("@/composables/storageSelection", () => ({ + storageSelection: () => ({ + getBucketById: vi.fn(() => null), + }), +})); + +describe("OAuthProviderFormModal", () => { + const mockProvider: OAuthProviderDto = { + _id: "oauth-1", + type: DocType.OAuthProvider, + updatedTimeUtc: Date.now(), + memberOf: [], + label: "Test Provider", + providerType: "auth0", + domain: "", + clientId: "", + audience: "", + }; + + const defaultProps = { + isVisible: true, + provider: mockProvider, + isEditing: false, + isLoading: false, + errors: undefined, + availableGroups: [mockData.mockGroup] as GroupDto[], + canDelete: false, + isFormValid: true, + hasAttemptedSubmit: false, + }; + + beforeEach(() => { + defaultProps.provider = { ...mockProvider }; + }); + + async function expandCredentials(wrapper: ReturnType) { + const buttons = wrapper.findAll("button"); + const setButton = buttons.find((b) => /Set|Update/.test(b.text())); + if (setButton) await setButton.trigger("click"); + await wrapper.vm.$nextTick(); + } + + it("renders credential inputs (domain, clientId, clientSecret, audience)", async () => { + const wrapper = mount(OAuthProviderFormModal, { + props: defaultProps, + global: { + stubs: { ImageEditor: true }, + }, + }); + await expandCredentials(wrapper); + expect(wrapper.text()).toContain("Domain"); + expect(wrapper.text()).toContain("Client ID"); + expect(wrapper.text()).toContain("Audience"); + }); + + it("shows domain, clientId, audience inside inputs when provider has those values", async () => { + const provider: OAuthProviderDto = { + ...mockProvider, + domain: "tenant.auth0.com", + clientId: "client123", + audience: "https://api.example.com", + }; + const wrapper = mount(OAuthProviderFormModal, { + props: { + ...defaultProps, + provider: provider, + }, + global: { + stubs: { ImageEditor: true }, + }, + }); + await expandCredentials(wrapper); + const inputs = wrapper.findAllComponents(LInput); + const byName = (name: string) => + inputs.find((w) => w.props("name") === name); + expect(byName("domain")?.props("modelValue")).toBe("tenant.auth0.com"); + expect(byName("clientId")?.props("modelValue")).toBe("client123"); + expect(byName("audience")?.props("modelValue")).toBe( + "https://api.example.com", + ); + }); + + it("binds credential inputs to provider model", async () => { + const wrapper = mount(OAuthProviderFormModal, { + props: defaultProps, + global: { + stubs: { ImageEditor: true }, + }, + }); + await expandCredentials(wrapper); + const domainInput = wrapper + .findAllComponents(LInput) + .find((w) => w.props("name") === "domain"); + expect(domainInput?.props("modelValue")).toBe(""); + await wrapper.setProps({ + provider: { + ...mockProvider, + domain: "updated.com", + clientId: "", + audience: "", + }, + }); + await wrapper.vm.$nextTick(); + expect( + wrapper + .findAllComponents(LInput) + .find((w) => w.props("name") === "domain") + ?.props("modelValue"), + ).toBe("updated.com"); + }); +}); diff --git a/cms/src/components/oAuthProvider/OAuthProviderFormModal.vue b/cms/src/components/oAuthProvider/OAuthProviderFormModal.vue new file mode 100644 index 0000000000..de09821d70 --- /dev/null +++ b/cms/src/components/oAuthProvider/OAuthProviderFormModal.vue @@ -0,0 +1,918 @@ + + + diff --git a/cms/src/components/oAuthProvider/OAuthProviderOverview.spec.ts b/cms/src/components/oAuthProvider/OAuthProviderOverview.spec.ts new file mode 100644 index 0000000000..aa46d3c204 --- /dev/null +++ b/cms/src/components/oAuthProvider/OAuthProviderOverview.spec.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import OAuthProviderOverview from "./OAuthProviderOverview.vue"; +import OAuthProviderFormModal from "./OAuthProviderFormModal.vue"; +import OAuthProviderDisplayCard from "./OAuthProviderDisplayCard.vue"; +import { db, accessMap } from "luminary-shared"; +import * as mockData from "@/tests/mockdata"; +import { setActivePinia } from "pinia"; +import waitForExpect from "wait-for-expect"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +vi.mock("@auth0/auth0-vue", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useAuth0: () => ({ + user: { name: "Test User", email: "test@example.com" }, + isAuthenticated: true, + isLoading: false, + }), + }; +}); + +vi.mock("vue-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + useRouter: () => ({ + push: vi.fn(), + currentRoute: { value: { path: "/oauth-providers" } }, + }), + useRoute: () => ({ + params: {}, + query: {}, + }), + }; +}); + +describe("OAuthProviderOverview", () => { + let pinia: ReturnType; + + beforeEach(async () => { + pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + await db.docs.clear(); + accessMap.value = mockData.superAdminAccessMap; + await db.docs.add(mockData.mockGroup); + await db.docs.add(mockData.mockAdminGroup); + }); + + afterEach(async () => { + await db.docs.clear(); + }); + + it("renders and shows empty state when there are no providers", async () => { + const wrapper = mount(OAuthProviderOverview, { + global: { + plugins: [pinia], + }, + }); + await wait(50); + expect(wrapper.text()).toContain("No OAuth configured"); + expect(wrapper.text()).toContain( + "Get started by creating your first OAuth configuration", + ); + }); + + it("prefills domain, clientId, audience when opening edit modal", async () => { + await db.docs.add(mockData.mockOAuthProviderDto); + const wrapper = mount(OAuthProviderOverview, { + global: { + plugins: [pinia], + }, + }); + await waitForExpect(() => { + expect( + wrapper.findAllComponents(OAuthProviderDisplayCard).length, + ).toBe(1); + }); + const card = wrapper.findComponent(OAuthProviderDisplayCard); + await card.vm.$emit("edit", mockData.mockOAuthProviderDto); + await wrapper.vm.$nextTick(); + const modal = wrapper.findComponent(OAuthProviderFormModal); + expect(modal.props("provider")).toMatchObject({ + domain: "tenant.auth0.com", + clientId: "client123", + audience: "https://api.example.com", + }); + }); + + it("save updates public fields", async () => { + const provider = { + ...mockData.mockOAuthProviderDto, + }; + await db.docs.add(provider); + const upsertSpy = vi.spyOn(db, "upsert"); + const wrapper = mount(OAuthProviderOverview, { + global: { + plugins: [pinia], + }, + }); + await waitForExpect(() => { + expect( + wrapper.findAllComponents(OAuthProviderDisplayCard).length, + ).toBe(1); + }); + const card = wrapper.findComponent(OAuthProviderDisplayCard); + await card.vm.$emit("edit", provider); + await wrapper.vm.$nextTick(); + const modal = wrapper.findComponent(OAuthProviderFormModal); + await modal.vm.$emit("update:provider", { + ...provider, + domain: "updated-domain.auth0.com", + }); + await wrapper.vm.$nextTick(); + await modal.vm.$emit("save"); + await waitForExpect(() => { + expect(upsertSpy).toHaveBeenCalled(); + }); + const upsertCall = + upsertSpy.mock.calls[upsertSpy.mock.calls.length - 1]; + const doc = upsertCall[0]?.doc as { domain?: string } | undefined; + expect(doc).toBeDefined(); + expect(doc?.domain).toBe("updated-domain.auth0.com"); + upsertSpy.mockRestore(); + }); + + it("validation: editing with partial secret invalid", async () => { + await db.docs.add(mockData.mockOAuthProviderDto); + const upsertSpy = vi.spyOn(db, "upsert"); + const wrapper = mount(OAuthProviderOverview, { + global: { + plugins: [pinia], + }, + }); + await waitForExpect(() => { + expect( + wrapper.findAllComponents(OAuthProviderDisplayCard).length, + ).toBe(1); + }); + const card = wrapper.findComponent(OAuthProviderDisplayCard); + await card.vm.$emit("edit", mockData.mockOAuthProviderDto); + await wrapper.vm.$nextTick(); + const modal = wrapper.findComponent(OAuthProviderFormModal); + await modal.vm.$emit("update:provider", { + ...mockData.mockOAuthProviderDto, + domain: "tenant.auth0.com", + clientId: "", + }); + await wrapper.vm.$nextTick(); + await modal.vm.$emit("save"); + await wait(100); + expect(upsertSpy).not.toHaveBeenCalled(); + upsertSpy.mockRestore(); + }); + + it("create requires full credentials", async () => { + const upsertSpy = vi.spyOn(db, "upsert"); + const wrapper = mount(OAuthProviderOverview, { + global: { + plugins: [pinia], + }, + }); + (wrapper.vm as { openCreateModal: () => void }).openCreateModal(); + await wrapper.vm.$nextTick(); + const modal = wrapper.findComponent(OAuthProviderFormModal); + await modal.vm.$emit("save"); + await wait(100); + expect(upsertSpy).not.toHaveBeenCalled(); + + const provider = modal.props("provider"); + await modal.vm.$emit("update:provider", { + ...provider, + label: "New Provider", + domain: "tenant.auth0.com", + clientId: "client123", + audience: "https://api.example.com", + }); + await wrapper.vm.$nextTick(); + await modal.vm.$emit("save"); + await waitForExpect(() => { + expect(upsertSpy).toHaveBeenCalled(); + }); + upsertSpy.mockRestore(); + }); +}); diff --git a/cms/src/components/oAuthProvider/OAuthProviderOverview.vue b/cms/src/components/oAuthProvider/OAuthProviderOverview.vue new file mode 100644 index 0000000000..2b93e3207a --- /dev/null +++ b/cms/src/components/oAuthProvider/OAuthProviderOverview.vue @@ -0,0 +1,412 @@ + + + diff --git a/cms/src/main.ts b/cms/src/main.ts index 56995664d3..9a6ae9695e 100644 --- a/cms/src/main.ts +++ b/cms/src/main.ts @@ -6,7 +6,7 @@ import App from "./App.vue"; import router from "./router"; import { DocType, getSocket, init } from "luminary-shared"; import { apiUrl, initLanguage } from "@/globalConfig"; -import auth, { isAuthBypassed } from "./auth"; +import auth, { isAuthBypassed, showProviderSelectionModal } from "./auth"; import { useNotificationStore } from "./stores/notification"; import { changeReqWarnings, changeReqErrors } from "luminary-shared"; import { initLanguageSync, initSync } from "./sync"; @@ -39,6 +39,11 @@ async function Startup() { apiUrl, token, syncList: [ + { + type: DocType.OAuthProvider, + syncPriority: 1, + skipWaitForLanguageSync: true, + }, { type: DocType.Tag, syncPriority: 2, @@ -82,12 +87,12 @@ async function Startup() { const socket = getSocket(); - // Redirect to login if the API authentication fails (skip in auth bypass mode) + // Show login modal if the API authentication fails (skip in auth bypass mode) if (!isAuthBypassed) { - socket.on("apiAuthFailed", async () => { - console.error("API authentication failed, redirecting to login"); - Sentry.captureMessage("API authentication failed, redirecting to login"); - await auth.loginRedirect(oauth); + socket.on("apiAuthFailed", () => { + console.error("API authentication failed, showing login modal"); + Sentry.captureMessage("API authentication failed, showing login modal"); + showProviderSelectionModal.value = true; }); } @@ -121,6 +126,11 @@ async function Startup() { app.use(createPinia()); app.use(router); app.mount("#app"); + + // Show the provider selection modal on startup if the user is not authenticated + if (!isAuthBypassed && !oauth.isAuthenticated.value) { + showProviderSelectionModal.value = true; + } } Startup(); diff --git a/cms/src/pages/OAuthProviderPage.vue b/cms/src/pages/OAuthProviderPage.vue new file mode 100644 index 0000000000..fc9d3cfacc --- /dev/null +++ b/cms/src/pages/OAuthProviderPage.vue @@ -0,0 +1,47 @@ + + + diff --git a/cms/src/stores/authProvider.ts b/cms/src/stores/authProvider.ts new file mode 100644 index 0000000000..571a2e76a2 --- /dev/null +++ b/cms/src/stores/authProvider.ts @@ -0,0 +1,57 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import type { OAuthProviderPublicDto } from "luminary-shared"; + +const PROVIDER_ID_KEY = "selectedProviderId"; + +/** + * Module-level ref so the fetch interceptor can read the provider ID + * without needing a Pinia context. Initialized from localStorage so it + * survives the Auth0 redirect/page-reload cycle. + */ +export const selectedProviderId = ref(localStorage.getItem(PROVIDER_ID_KEY)); + +export const useAuthProviderStore = defineStore("authProvider", () => { + const selectedProvider = ref(null); + + function setProvider(provider: OAuthProviderPublicDto) { + selectedProviderId.value = provider._id; + selectedProvider.value = provider; + localStorage.setItem(PROVIDER_ID_KEY, provider._id); + } + + function clearProvider() { + selectedProviderId.value = null; + selectedProvider.value = null; + localStorage.removeItem(PROVIDER_ID_KEY); + } + + /** + * Clear all Auth0 tokens from localStorage. + * Auth0 SPA JS stores entries with the prefix @@auth0spajs@@ and a0.spajs. + */ + function clearAuth0Tokens() { + const keysToRemove = Object.keys(localStorage).filter( + (k) => k.startsWith("@@auth0spajs@@") || k.startsWith("a0.spajs"), + ); + keysToRemove.forEach((k) => localStorage.removeItem(k)); + } + + /** + * Global logout: clears the in-memory provider, wipes stored Auth0 tokens, + * and reloads the page so the app reconnects anonymously (public access). + */ + function globalLogout() { + clearProvider(); + clearAuth0Tokens(); + // Reload so the socket reconnects without a token, restoring public access + window.location.href = window.location.origin; + } + + return { + selectedProvider, + setProvider, + clearProvider, + globalLogout, + }; +}); diff --git a/cms/src/sync.ts b/cms/src/sync.ts index f38e5c2164..17bfd52bca 100644 --- a/cms/src/sync.ts +++ b/cms/src/sync.ts @@ -190,6 +190,20 @@ export function initSync() { Sentry?.captureException(err); }); } + + // Sync OAuth providers + if (access[DocType.OAuthProvider] && access[DocType.OAuthProvider].length) { + sync({ + type: DocType.OAuthProvider, + memberOf: access[DocType.OAuthProvider], + limit: 1000, + cms: true, + includeDeleteCmds: true, + }).catch((err) => { + console.error("Error during OAuth provider sync:", err); + Sentry?.captureException(err); + }); + } }, ); } diff --git a/cms/src/tests/mockdata.ts b/cms/src/tests/mockdata.ts index fadd1b881e..c9d6f860fb 100644 --- a/cms/src/tests/mockdata.ts +++ b/cms/src/tests/mockdata.ts @@ -20,6 +20,7 @@ import { StorageType, MediaType, type MediaFileDto, + type OAuthProviderDto, } from "luminary-shared"; export const mockCategoryDto: TagDto = { @@ -865,6 +866,12 @@ export const superAdminAccessMap = { assign: true, delete: true, }, + oAuthProvider: { + view: true, + edit: true, + assign: true, + delete: true, + }, }, "group-private-content": { post: { @@ -1444,6 +1451,18 @@ export const mockStorageDtoGeneral: StorageDto = { mimeTypes: ["image/*", "video/mp4", "application/pdf"], }; +export const mockOAuthProviderDto: OAuthProviderDto = { + _id: "oauth-provider-1", + type: DocType.OAuthProvider, + updatedTimeUtc: 1704114000000, + memberOf: ["group-super-admins"], + label: "Production Auth0", + providerType: "auth0", + domain: "tenant.auth0.com", + clientId: "client123", + audience: "https://api.example.com", +}; + // Alias for backward compatibility export const mockGroup = mockGroupDtoPublicContent; export const mockAdminGroup = mockGroupDtoSuperAdmins; diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000000..daac499238 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Luminary setup: grant user groups, setup OAuth providers. Uses curl + jq. Run from repo root; API can stay running. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +API_ENV="$REPO_ROOT/api/.env" + +# Load .env from api/ or cwd +load_env() { + for f in "$API_ENV" "$REPO_ROOT/.env" ".env"; do + [[ -r "$f" ]] && while IFS= read -r line; do + [[ "$line" =~ ^#.*$ ]] && continue + if [[ "$line" =~ ^([^=]+)=(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + val="${BASH_REMATCH[2]}" + val="${val%\"}"; val="${val#\"}"; val="${val%\'}"; val="${val#\'}" + export "$key=$val" + fi + done < "$f" && break + done +} +load_env + +command -v jq >/dev/null 2>&1 || { echo "jq is required. Install with: brew install jq"; exit 1; } + +DEFAULT_DB="${DB_DATABASE:-luminary-local}" +VIEW_NAME="view-user-email-userId" +PROVIDER_PUBLIC_GROUP="group-public-content" + +prompt() { read -r -p "$1" reply; echo "$reply"; } + +# CouchDB base URL and auth for curl. Uses DB_CONNECTION_STRING or prompts. +get_couch_opts() { + if [[ -n "$DB_CONNECTION_STRING" ]]; then + echo "Using DB_CONNECTION_STRING and DB_DATABASE from environment." + COUCH_URL="$DB_CONNECTION_STRING" + COUCH_DB="${DB_DATABASE:-$DEFAULT_DB}" + return + fi + local base + base=$(prompt "CouchDB URL (e.g. http://127.0.0.1:5984): ") + base=${base:-http://127.0.0.1:5984} + local user pass + user=$(prompt "CouchDB Username: ") + pass=$(prompt "CouchDB Password: ") + COUCH_DB=$(prompt "CouchDB Database (default $DEFAULT_DB): ") + COUCH_DB=${COUCH_DB:-$DEFAULT_DB} + if [[ -n "$user" || -n "$pass" ]]; then + local host="${base#*://}" + COUCH_URL="${base%%://*}://${user}:${pass}@${host}" + else + COUCH_URL="$base" + fi +} + +couch_get() { + curl -sS -f -X GET "${COUCH_URL}/${COUCH_DB}/${1}" +} + +couch_put() { + curl -sS -f -X PUT -H "Content-Type: application/json" -d "$2" "${COUCH_URL}/${COUCH_DB}/${1}" +} + +couch_view() { + curl -sS -f -X POST -H "Content-Type: application/json" \ + -d "{\"keys\":[\"$1\"]}" \ + "${COUCH_URL}/${COUCH_DB}/_design/${VIEW_NAME}/_view/${VIEW_NAME}?include_docs=true" +} + +# --- Menu 1: Grant groups to user by email --- +grant_groups() { + local group_input email confirm + group_input=$(prompt "Group ID(s) to add to user, comma-separated: ") + group_input=$(echo "$group_input" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | tr '\n' ',' | sed 's/,$//') + [[ -z "$group_input" ]] && { echo "At least one group ID is required."; exit 1; } + email=$(prompt "Email: ") + [[ -z "$email" ]] && { echo "Email is required."; exit 1; } + confirm=$(prompt "Grant access ($group_input) to $email? (Y/N): ") + [[ ! "$confirm" =~ ^[Yy] ]] && [[ ! "$confirm" =~ ^[Yy]es$ ]] && { echo "Skipped."; return; } + + local res doc id rev new_member_of payload + res=$(couch_view "$email") || { echo "View request failed."; exit 1; } + doc=$(echo "$res" | jq -c '.rows[0].doc // empty') + [[ -z "$doc" || "$doc" == "null" ]] && { echo "No user found for email: $email"; exit 1; } + id=$(echo "$doc" | jq -r '._id') + rev=$(echo "$doc" | jq -r '._rev') + new_member_of=$(echo "$doc" | jq -c --arg gs "$group_input" ' + (.memberOf // []) as $m | + ($gs | split(",") | map(gsub("^ *";"") | gsub(" *$";"")) | map(select(length>0))) as $add | + ($m + $add) | unique + ') + payload=$(echo "$doc" | jq -c --argjson m "$new_member_of" '.memberOf = $m | .updatedTimeUtc = (now * 1000 | floor)') + couch_put "$id" "$(echo "$payload" | jq -c --arg r "$rev" '. + {_rev:$r}')" + echo "Access granted to $email." +} + +# --- Menu 2/3: Setup auth providers --- +setup_providers() { + local domain client_id audience label claim_ns + claim_ns=$(prompt "Claim namespace (optional): ") + local uf_user uf_email uf_name + uf_user=$(prompt " User field for userId inside namespace (optional): ") + uf_email=$(prompt " User field for email inside namespace (optional): ") + uf_name=$(prompt " User field for name inside namespace (optional): ") + local claim_mapping + claim_mapping=$(prompt "Claim names that map to groups, comma-separated: ") + local default_groups + default_groups=$(prompt "Group ID(s) to assign when users log in with this provider (comma-separated): ") + default_groups=$(echo "$default_groups" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | tr '\n' ',' | sed 's/,$//') + local provider_visibility + provider_visibility=$(prompt "Group ID(s) for provider visibility, comma-separated (always includes $PROVIDER_PUBLIC_GROUP): ") + local guest_group + guest_group=$(prompt "Group ID to assign to guest users (optional): ") + + domain=$(prompt "Auth Domain (e.g. tenant.auth0.com): ") + client_id=$(prompt "Auth Client ID: ") + audience=$(prompt "Auth Audience: ") + label=$(prompt "Provider label (optional, default: Default Provider): ") + label=${label:-Default Provider} + [[ -z "$domain" || -z "$client_id" || -z "$audience" ]] && { echo "Domain, Client ID, and Audience are required."; exit 1; } + + local member_of_arr + member_of_arr=$(echo "$provider_visibility" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | jq -R -s -c 'split("\n") | map(select(length>0)) | . + ["'"$PROVIDER_PUBLIC_GROUP"'"] | unique') + [[ "$member_of_arr" == "null" || -z "$member_of_arr" ]] && member_of_arr="[\"$PROVIDER_PUBLIC_GROUP\"]" + + local default_doc + default_doc=$(jq -n -c \ + --arg domain "$(echo "$domain" | tr '[:upper:]' '[:lower:]')" \ + --arg client_id "$client_id" \ + --arg audience "$audience" \ + --arg label "$label" \ + --argjson member_of "$member_of_arr" \ + '{_id:"oAuthProvider-default",type:"oAuthProvider",label:$label,providerType:"auth0",domain:$domain,clientId:$client_id,audience:$audience,memberOf:$member_of,updatedTimeUtc:(now*1000|floor)}') + [[ -n "$claim_ns" ]] && default_doc=$(echo "$default_doc" | jq -c --arg ns "$claim_ns" '. + {claimNamespace:$ns}') + if [[ -n "$uf_user" || -n "$uf_email" || -n "$uf_name" ]]; then + default_doc=$(echo "$default_doc" | jq -c --arg u "$uf_user" --arg e "$uf_email" --arg n "$uf_name" '. + {userFieldMappings:{userId:(if $u=="" then null else $u end),email:(if $e=="" then null else $e end),name:(if $n=="" then null else $n end)}}') + fi + if [[ -n "$default_groups" ]]; then + local ga + ga=$(echo "$default_groups" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' | jq -R -s -c 'split("\n") | map(select(length>0)) | map({groupId:.,conditions:[{type:"authenticated"}]})') + default_doc=$(echo "$default_doc" | jq -c --argjson ga "$ga" '. + {groupAssignments:$ga}') + fi + + local existing + existing=$(couch_get "oAuthProvider-default" 2>/dev/null || true) + if [[ -n "$existing" ]] && echo "$existing" | jq -e '._rev' >/dev/null 2>&1; then + default_doc=$(echo "$default_doc" | jq -c --arg rev "$(echo "$existing" | jq -r '._rev')" '. + {_rev:$rev}') + fi + couch_put "oAuthProvider-default" "$default_doc" + echo "Default OAuth provider updated (domain: $domain)." + + local guest_doc + guest_doc=$(jq -n -c --argjson member_of "$member_of_arr" \ + '{_id:"oAuthProvider-guest",type:"oAuthProvider",label:"Guest",providerType:"auth0",isGuestProvider:true,memberOf:$member_of,updatedTimeUtc:(now*1000|floor)}') + if [[ -n "$guest_group" ]]; then + guest_doc=$(echo "$guest_doc" | jq -c --arg g "$guest_group" '. + {groupAssignments:[{groupId:$g,conditions:[{type:"always"}]}]}') + fi + existing=$(couch_get "oAuthProvider-guest" 2>/dev/null || true) + if [[ -n "$existing" ]] && echo "$existing" | jq -e '._rev' >/dev/null 2>&1; then + guest_doc=$(echo "$guest_doc" | jq -c --arg rev "$(echo "$existing" | jq -r '._rev')" '. + {_rev:$rev}') + fi + couch_put "oAuthProvider-guest" "$guest_doc" + echo "Guest OAuth provider updated." +} + +# --- Main --- +echo "" +echo "Setup (Luminary)" +echo "1. Give super-admin access to initial user (by email)" +echo "2. Setup initial auth provider + guest auth provider" +echo "3. Setup auth providers and initial super-admin user" +echo "" +choice=$(prompt "Choice (1/2/3): ") +[[ "$choice" != "1" && "$choice" != "2" && "$choice" != "3" ]] && { echo "Invalid choice."; exit 1; } + +get_couch_opts + +if [[ "$choice" == "2" || "$choice" == "3" ]]; then + setup_providers +fi +if [[ "$choice" == "1" || "$choice" == "3" ]]; then + grant_groups +fi +echo "Done." \ No newline at end of file diff --git a/shared/src/types/dto.ts b/shared/src/types/dto.ts index 8e6ed7067e..ede27d1b1e 100644 --- a/shared/src/types/dto.ts +++ b/shared/src/types/dto.ts @@ -225,6 +225,66 @@ export type RedirectDto = ContentBaseDto & { toSlug?: string; }; +export type GroupAssignmentCondition = + | { type: "always" } + | { type: "authenticated" } + | { type: "claimEquals"; claimPath: string; value: string } + | { type: "claimIn"; claimPath: string; values: string[] }; + +export type GroupAssignment = { + groupId: string; + conditions: GroupAssignmentCondition[]; +}; + +export type UserFieldMappings = { + userId?: string; + email?: string; + name?: string; +}; + +export type OAuthProviderDto = ContentBaseDto & { + label: string; + providerType: "auth0"; + textColor?: string; + backgroundColor?: string; + clientId?: string; + domain?: string; + audience?: string; + icon?: string; + iconOpacity?: number; + imageData?: ImageDto; + imageBucketId?: Uuid; + claimNamespace?: string; + claimMappings?: Array<{ claim: string; target: string }>; + userFieldMappings?: UserFieldMappings; + groupAssignments?: GroupAssignment[]; + isGuestProvider?: boolean; +}; + +/** + * Public-facing subset of OAuthProviderDto for client-side display and authentication. + * Excludes internal claim mapping configuration. + */ +export type OAuthProviderPublicDto = Pick< + OAuthProviderDto, + | "_id" + | "type" + | "updatedTimeUtc" + | "memberOf" + | "label" + | "providerType" + | "textColor" + | "backgroundColor" + | "clientId" + | "domain" + | "audience" + | "icon" + | "iconOpacity" + | "imageData" + | "imageBucketId" + | "isGuestProvider" +>; + /** * This type is an exact copy of the API's DbQueryResult, which is passed to the client when querying the Search API endpoint */ diff --git a/shared/src/types/enum.ts b/shared/src/types/enum.ts index 0bf4ab9691..088aa32561 100644 --- a/shared/src/types/enum.ts +++ b/shared/src/types/enum.ts @@ -9,6 +9,7 @@ export enum DocType { DeleteCmd = "deleteCmd", Storage = "storage", Crypto = "crypto", + OAuthProvider = "oAuthProvider", } export enum PublishStatus { From 4bf1400fb823ef5cacd992439092baba59ccb9cd Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 9 Mar 2026 11:25:14 +0200 Subject: [PATCH 02/13] chore: Update vitest configuration across multiple files to use 'forks' pool for improved parallel processing --- app/vitest.config.ts | 2 ++ cms/src/components/content/query.spec.ts | 12 +++++++++--- cms/vitest.config.ts | 2 ++ shared/vitest.config.ts | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/vitest.config.ts b/app/vitest.config.ts index 626b8ffe3b..954348d23d 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -34,6 +34,8 @@ export default mergeConfig( exclude: [...configDefaults.exclude, "e2e/*"], root: fileURLToPath(new URL("./", import.meta.url)), setupFiles: ["vitest.setup.ts"], + // Forks give each worker its own process → own fake-indexeddb, so parallel files don't race. + pool: "forks", }, }), ); diff --git a/cms/src/components/content/query.spec.ts b/cms/src/components/content/query.spec.ts index ef00dc3d11..75d5442d88 100644 --- a/cms/src/components/content/query.spec.ts +++ b/cms/src/components/content/query.spec.ts @@ -94,10 +94,14 @@ describe("Content query", () => { langSwa, ]); - // Verify database is ready + // Verify seed content docs are visible (avoids flakiness when run with full suite) await waitForExpect(async () => { - const dbDocs = await db.docs.toArray(); - expect(dbDocs.length).toBeGreaterThan(0); + const doc1 = await db.docs.get("doc1Eng"); + const doc2 = await db.docs.get("doc2Eng"); + expect(doc1).toBeDefined(); + expect(doc2).toBeDefined(); + expect((doc1 as ContentDto).title).toBe("Doc 1 Eng"); + expect((doc2 as ContentDto).title).toBe("Doc 2 Eng"); }); }); @@ -383,7 +387,9 @@ describe("Content query", () => { const res1DocsAsContent = res1.value?.docs as ContentDto[]; expect(res1DocsAsContent).toHaveLength(1); expect(res1DocsAsContent[0].title).toBe("Doc 1 Eng"); + }); + await waitForExpect(() => { const res2DocsAsContent = res2.value?.docs as ContentDto[]; expect(res2DocsAsContent).toHaveLength(1); expect(res2DocsAsContent[0].title).toBe("Doc 2 Eng"); diff --git a/cms/vitest.config.ts b/cms/vitest.config.ts index e59134a0d7..b95245d0b7 100644 --- a/cms/vitest.config.ts +++ b/cms/vitest.config.ts @@ -11,6 +11,8 @@ export default mergeConfig( exclude: [...configDefaults.exclude, "e2e/*"], root: fileURLToPath(new URL("./", import.meta.url)), setupFiles: ["vitest.setup.ts"], + // Forks give each worker its own process → own fake-indexeddb, so parallel files don't race. + pool: "forks", }, }), ); diff --git a/shared/vitest.config.ts b/shared/vitest.config.ts index a8bef08b08..af7455bf2c 100644 --- a/shared/vitest.config.ts +++ b/shared/vitest.config.ts @@ -10,6 +10,8 @@ export default mergeConfig( environment: "jsdom", exclude: [...configDefaults.exclude, "e2e/*"], root: fileURLToPath(new URL("./", import.meta.url)), + // Forks give each worker its own process → own fake-indexeddb, so parallel files don't race. + pool: "forks", }, }), ); From fd20d8f5e4c15d7b647470614476a1a0f13efc00 Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 9 Mar 2026 13:02:36 +0200 Subject: [PATCH 03/13] Implement per user auth provider ownership --- api/src/auth/auth-identity.service.ts | 116 +++++++++++------- api/src/db/db.upgrade.ts | 2 + .../type-providerIdentifiers-index.json | 25 ++++ api/src/db/schemaUpgrade/v14.ts | 57 +++++++++ api/src/dto/UserDto.ts | 35 +++++- api/src/jwt/processJwt.ts | 17 +++ cms/src/components/users/CreateOrEditUser.vue | 24 ++++ shared/src/types/dto.ts | 4 + 8 files changed, 231 insertions(+), 49 deletions(-) create mode 100644 api/src/db/designDocs/type-providerIdentifiers-index.json create mode 100644 api/src/db/schemaUpgrade/v14.ts diff --git a/api/src/auth/auth-identity.service.ts b/api/src/auth/auth-identity.service.ts index af628c0cfc..31c86c3223 100644 --- a/api/src/auth/auth-identity.service.ts +++ b/api/src/auth/auth-identity.service.ts @@ -1,6 +1,5 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; import { JwtPayload } from "jsonwebtoken"; -import { randomUUID } from "crypto"; import { DbService } from "../db/db.service"; import { DocType } from "../enums"; import { GroupAssignment, GroupAssignmentCondition, OAuthProviderDto } from "../dto/OAuthProviderDto"; @@ -12,7 +11,7 @@ import { UserDto } from "../dto/UserDto"; */ export type ResolvedIdentity = { user: UserDto; - /** Group IDs that passed all conditions in groupAssignments. Falls back to the guest group. */ + /** Effective group IDs: union of provider-derived groups and user.memberOf. Falls back to the guest group. */ groupIds: string[]; }; @@ -25,12 +24,17 @@ export class AuthIdentityService { /** * Main entry point. Given a verified JWT payload and the matching OAuthProvider document, - * resolves the user identity (find-or-provision) and evaluates all groupAssignment rules. + * resolves the user identity and evaluates all groupAssignment rules. + * + * Effective groupIds = union of user.memberOf (DB-assigned) and provider-derived groups. */ async resolveIdentity(payload: JwtPayload, provider: OAuthProviderDto): Promise { const { userId, email, name } = this.extractUserFields(payload, provider); - const user = await this.findOrProvisionUser({ userId, email, name, providerId: provider._id }); - const groupIds = this.evaluateGroupAssignments(payload, provider.groupAssignments ?? []); + const user = await this.findUser({ userId, email, name, providerId: provider._id }); + const providerGroupIds = this.evaluateGroupAssignments(payload, provider.groupAssignments ?? []); + + // Merge DB-assigned groups with provider-derived groups + const groupIds = [...new Set([...(user.memberOf ?? []), ...providerGroupIds])]; return { user, groupIds }; } @@ -88,10 +92,16 @@ export class AuthIdentityService { } /** - * Looks up an existing user by (oAuthProviderId, userId) or creates a new one. - * The combination of provider ID + external user ID is globally unique. + * Resolves a user by (providerId, userId) using the new providerIdentifiers structure, + * with a fallback to the legacy oAuthProviderId + userId compound key. + * + * If no user is found, throws UnauthorizedException — users must be created in the CMS + * before they can log in. + * + * If the user is found but the current provider is not yet in their providers list, + * the provider is added to providers and providerIdentifiers ("add on login"). */ - private async findOrProvisionUser(opts: { + private async findUser(opts: { userId: string; email: string; name: string; @@ -99,54 +109,66 @@ export class AuthIdentityService { }): Promise { const { userId, email, name, providerId } = opts; - // Query by the compound key (oAuthProviderId + userId) - const result = await this.db.executeFindQuery({ + // 1. Primary lookup: providerIdentifiers $elemMatch + let result = await this.db.executeFindQuery({ selector: { type: DocType.User, - oAuthProviderId: providerId, - userId, + providerIdentifiers: { $elemMatch: { providerId, userId } }, }, limit: 1, }); - if (result.docs?.length > 0) { - const existing = result.docs[0] as UserDto; - - // Refresh mutable fields that may have changed in the identity provider - const needsUpdate = - (email && existing.email !== email) || - (name && existing.name !== name); - - if (needsUpdate) { - const updated: UserDto = { - ...existing, - ...(email ? { email } : {}), - ...(name ? { name } : {}), - lastLogin: Date.now(), - }; - await this.db.upsertDoc(updated); - return updated; - } + // 2. Fallback: legacy oAuthProviderId + userId compound key + if (!result.docs?.length) { + result = await this.db.executeFindQuery({ + selector: { + type: DocType.User, + oAuthProviderId: providerId, + userId, + }, + limit: 1, + }); + } - // Touch lastLogin without triggering a full update cycle - await this.db.upsertDoc({ ...existing, lastLogin: Date.now() }); - return existing; + if (!result.docs?.length) { + throw new UnauthorizedException( + "No user found for this provider identity. Please contact an administrator.", + ); } - // Provision a new user document - const newUser: UserDto = { - _id: randomUUID(), - type: DocType.User, - memberOf: [], - oAuthProviderId: providerId, - userId, - email: email || `${userId}@unknown`, - name: name || userId, - lastLogin: Date.now(), - }; + let user = result.docs[0] as UserDto; + + // Determine which fields need updating + const needsEmailUpdate = email && user.email !== email; + const needsNameUpdate = name && user.name !== name; + + // Check if this provider is already recorded on the user + const alreadyLinked = + user.providerIdentifiers?.some((pi) => pi.providerId === providerId && pi.userId === userId) ?? false; + + if (needsEmailUpdate || needsNameUpdate || !alreadyLinked) { + user = { + ...user, + ...(needsEmailUpdate ? { email } : {}), + ...(needsNameUpdate ? { name } : {}), + lastLogin: Date.now(), + }; + + if (!alreadyLinked) { + user.providers = [...new Set([...(user.providers ?? []), providerId])]; + user.providerIdentifiers = [ + ...(user.providerIdentifiers ?? []), + { providerId, userId }, + ]; + } + + await this.db.upsertDoc(user); + return user; + } - await this.db.upsertDoc(newUser); - return newUser; + // Touch lastLogin + await this.db.upsertDoc({ ...user, lastLogin: Date.now() }); + return user; } // ------------------------------------------------------------------------- diff --git a/api/src/db/db.upgrade.ts b/api/src/db/db.upgrade.ts index ff466d16de..503033347f 100644 --- a/api/src/db/db.upgrade.ts +++ b/api/src/db/db.upgrade.ts @@ -4,6 +4,7 @@ import v10 from "./schemaUpgrade/v10"; import v11 from "./schemaUpgrade/v11"; import v12 from "./schemaUpgrade/v12"; import v13 from "./schemaUpgrade/v13"; +import v14 from "./schemaUpgrade/v14"; /** * Upgrade the database schema @@ -23,6 +24,7 @@ export async function upgradeDbSchema(db: DbService) { await v11(db); await v12(db); await v13(db); + await v14(db); } catch (error) { console.error("Database schema upgrade failed:", error); throw error; // Re-throw to prevent schema version from being updated diff --git a/api/src/db/designDocs/type-providerIdentifiers-index.json b/api/src/db/designDocs/type-providerIdentifiers-index.json new file mode 100644 index 0000000000..079c9ac89b --- /dev/null +++ b/api/src/db/designDocs/type-providerIdentifiers-index.json @@ -0,0 +1,25 @@ +{ + "_id": "_design/type-providerIdentifiers-index", + "language": "query", + "views": { + "type-providerIdentifiers-index": { + "map": { + "fields": { + "type": "asc", + "providerIdentifiers": "asc" + }, + "partial_filter_selector": { + "type": { + "$eq": "user" + } + } + }, + "reduce": "_count", + "options": { + "def": { + "fields": ["type", "providerIdentifiers"] + } + } + } + } +} diff --git a/api/src/db/schemaUpgrade/v14.ts b/api/src/db/schemaUpgrade/v14.ts new file mode 100644 index 0000000000..0aec78b8d4 --- /dev/null +++ b/api/src/db/schemaUpgrade/v14.ts @@ -0,0 +1,57 @@ +import { DbService } from "../db.service"; +import { DocType } from "../../enums"; + +/** + * Upgrade the database schema from version 13 to 14. + * + * For each User doc that has `oAuthProviderId` and `userId` but no `providerIdentifiers`: + * - Sets `providerIdentifiers: [{ providerId: doc.oAuthProviderId, userId: doc.userId }]` + * - Sets `providers: [doc.oAuthProviderId]` + * + * The legacy `oAuthProviderId` and `userId` fields are kept intact so existing code + * paths continue to work until identity resolution is fully switched over. + */ +export default async function (db: DbService) { + try { + const schemaVersion = await db.getSchemaVersion(); + if (schemaVersion !== 13) { + console.info(`Skipping schema upgrade v14: current version is ${schemaVersion}, expected 13`); + return; + } + + console.info("Upgrading database schema from version 13 to 14"); + + // Fetch all User documents + const result = await db.executeFindQuery({ + selector: { type: DocType.User }, + limit: 10000, + }); + + const userDocs = (result.docs ?? []) as Array>; + let migratedCount = 0; + + for (const doc of userDocs) { + const oAuthProviderId = doc["oAuthProviderId"] as string | undefined; + const userId = doc["userId"] as string | undefined; + + // Only migrate docs that have the legacy compound key but not the new structure + if (oAuthProviderId && userId && !doc["providerIdentifiers"]) { + const updated = { + ...doc, + providers: [oAuthProviderId], + providerIdentifiers: [{ providerId: oAuthProviderId, userId }], + }; + await db.upsertDoc(updated as any); + migratedCount++; + } + } + + console.info(`Migrated ${migratedCount} user doc(s) to providerIdentifiers/providers`); + + await db.setSchemaVersion(14); + console.info("Database schema upgrade from version 13 to 14 completed successfully"); + } catch (error) { + console.error("Database schema upgrade from version 13 to 14 failed:", error); + throw error; + } +} diff --git a/api/src/dto/UserDto.ts b/api/src/dto/UserDto.ts index 37a4f2093d..2f2b7ee898 100644 --- a/api/src/dto/UserDto.ts +++ b/api/src/dto/UserDto.ts @@ -1,6 +1,16 @@ -import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; +import { IsArray, IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; import { _contentBaseDto } from "./_contentBaseDto"; -import { Expose } from "class-transformer"; +import { Expose, Type } from "class-transformer"; + +export class ProviderIdentifierDto { + @IsString() + @Expose() + providerId: string; + + @IsString() + @Expose() + userId: string; +} /** * Database structured User object @@ -37,4 +47,25 @@ export class UserDto extends _contentBaseDto { @IsNumber() @Expose() lastLogin?: number; + + /** + * IDs of OAuthProvider documents this user belongs to. + * Used for CMS display and "add on login" linking. + */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Expose() + providers?: string[]; + + /** + * One entry per provider: the external user ID within that provider. + * Used for identity lookup via $elemMatch: { providerId, userId }. + */ + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProviderIdentifierDto) + @Expose() + providerIdentifiers?: ProviderIdentifierDto[]; } diff --git a/api/src/jwt/processJwt.ts b/api/src/jwt/processJwt.ts index fc0a3bdd83..de15a96721 100644 --- a/api/src/jwt/processJwt.ts +++ b/api/src/jwt/processJwt.ts @@ -58,13 +58,16 @@ export function clearJwtMap() { /** * Process a JWT token against the JWT_MAPPINGS (environmental variable) and return mapped groups and user details * @param jwt - Javascript Web Token + * @param db - Database service * @param logger - Logger instance + * @param providerId - Optional OAuthProvider _id; when supplied, added to the user's providers / providerIdentifiers on login * @returns - Array with JWT verified groups */ export async function processJwt( jwt: string, db: DbService, logger?: Logger, + providerId?: string, ): Promise { const groupSet = new Set(); let userId: string; @@ -140,6 +143,20 @@ export async function processJwt( if (name) { updated.name = name; } + // Add provider on login if not already linked + if (providerId) { + const alreadyLinked = updated.providerIdentifiers?.some( + (pi: { providerId: string; userId: string }) => + pi.providerId === providerId && pi.userId === userId, + ); + if (!alreadyLinked) { + updated.providers = [...new Set([...(updated.providers ?? []), providerId])]; + updated.providerIdentifiers = [ + ...(updated.providerIdentifiers ?? []), + { providerId, userId }, + ]; + } + } await db.upsertDoc(updated); } } else if (email) { diff --git a/cms/src/components/users/CreateOrEditUser.vue b/cms/src/components/users/CreateOrEditUser.vue index 4537a74352..cbc3bbbd90 100644 --- a/cms/src/components/users/CreateOrEditUser.vue +++ b/cms/src/components/users/CreateOrEditUser.vue @@ -18,6 +18,7 @@ import { useDexieLiveQuery, db, type GroupDto, + type OAuthProviderDto, } from "luminary-shared"; import { computed, ref, toRaw, watch } from "vue"; import _ from "lodash"; @@ -71,6 +72,11 @@ const groups = useDexieLiveQuery( { initialValue: [] as GroupDto[] }, ); +const oAuthProviders = useDexieLiveQuery( + () => db.docs.where({ type: DocType.OAuthProvider }).toArray() as unknown as Promise, + { initialValue: [] as OAuthProviderDto[] }, +); + // Check if the user is dirty (has unsaved changes) const isDirty = ref(false); watch( @@ -232,6 +238,24 @@ const saveDisabled = computed(() => { :disabled="!canEditOrCreate" data-test="groupSelector" /> + +