diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..d58517a38 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ory_infra/hydra"] + path = ory_infra/hydra + url = https://github.com/ory/hydra.git diff --git a/config.example.yaml b/config.example.yaml index 171016ff4..59e99eee0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -29,6 +29,10 @@ services: admin_endpoint: access_token: ORY_ACCESS_TOKEN schema_id: ALETHEIA_SCHEMA_ID + hydra: + url: ORY_SDK_URL + # When using the cloud, the endpoint should be "admin". + admin_endpoint: feature_flag: url: GITLAB_FEATURE_FLAG_URL appName: ENV diff --git a/deployment/config/config-file/modules/main.pkl b/deployment/config/config-file/modules/main.pkl index 63a2bdb75..3ba73af0d 100644 --- a/deployment/config/config-file/modules/main.pkl +++ b/deployment/config/config-file/modules/main.pkl @@ -21,6 +21,9 @@ hidden var = new { } ory = (oryConfig) { admin_endpoint = "admin" + hydra = new { + admin_endpoint = "admin" + } } } } diff --git a/deployment/config/config-file/modules/ory.pkl b/deployment/config/config-file/modules/ory.pkl index b804b978f..67c832889 100644 --- a/deployment/config/config-file/modules/ory.pkl +++ b/deployment/config/config-file/modules/ory.pkl @@ -3,3 +3,7 @@ admin_url = read("env:ORY_SDK_URL") admin_endpoint: String access_token = read("env:ORY_ACCESS_TOKEN") schema_id = read("env:ALETHEIA_SCHEMA_ID") +hydra = new { + url: read("env:ORY_SDK_URL") + admin_endpoint: String +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 7fde19cbf..af6abdafb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,10 @@ version: "3" services: + sqlite: + image: busybox + volumes: + - hydra-sqlite:/mnt/sqlite + command: "chmod -R 777 /mnt/sqlite" mongodb: container_name: mongodb image: mongo:6.0.17 @@ -9,7 +14,6 @@ services: - ${DATA_PATH}/mongodb/db:/data/db:delegated ports: - "${MONGODB_PORT:-27017}:27017" - localstack: container_name: aletheia-localstack image: localstack/localstack:3.7.2 @@ -81,7 +85,60 @@ services: - "4437:4437" networks: - intranet + hydra: + image: oryd/hydra:v2.3.0 + build: + context: . + dockerfile: .docker/Dockerfile-local-build + ports: + - "4444:4444" # Public port + - "4445:4445" # Admin port + - "5555:5555" # Port for hydra token user + command: serve -c /etc/config/hydra/hydra.yml all --dev + volumes: + - hydra-sqlite:/mnt/sqlite:rw + - type: bind + source: ./ory_config + target: /etc/config/hydra + pull_policy: missing + environment: + - DSN=sqlite:///mnt/sqlite/db.sqlite?_fk=true&mode=rwc + restart: unless-stopped + depends_on: + - hydra-migrate + - sqlite + networks: + - intranet + hydra-migrate: + image: oryd/hydra:v2.3.0 + build: + context: . + dockerfile: .docker/Dockerfile-local-build + environment: + - DSN=sqlite:///mnt/sqlite/db.sqlite?_fk=true&mode=rwc + command: migrate -c /etc/config/hydra/hydra.yml sql up -e --yes + pull_policy: missing + volumes: + - hydra-sqlite:/mnt/sqlite:rw + - type: bind + source: ./ory_config + target: /etc/config/hydra + restart: on-failure + networks: + - intranet + depends_on: + - sqlite + consent: + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + image: oryd/hydra-login-consent-node:v2.3.0 + ports: + - "3000:3000" + restart: unless-stopped + networks: + - intranet networks: intranet: volumes: kratos-sqlite: + hydra-sqlite: diff --git a/ory_config/hydra.yml b/ory_config/hydra.yml new file mode 100644 index 000000000..699b9a91f --- /dev/null +++ b/ory_config/hydra.yml @@ -0,0 +1,33 @@ +serve: + cookies: + same_site_mode: Lax + +urls: + self: + issuer: http://127.0.0.1:4444 + consent: http://127.0.0.1:3000/consent + login: http://127.0.0.1:3000/login + logout: http://127.0.0.1:3000/logout + +secrets: + system: + - youReallyNeedToChangeThis + +oidc: + subject_identifiers: + supported_types: + - pairwise + - public + pairwise: + salt: youReallyNeedToChangeThis + +strategies: + access_token: opaque + +ttl: + access_token: 1h + refresh_token: 720h + +oauth2: + client_credentials: + default_grant_allowed_scope: true diff --git a/ory_infra/hydra b/ory_infra/hydra new file mode 160000 index 000000000..a7579b8d1 --- /dev/null +++ b/ory_infra/hydra @@ -0,0 +1 @@ +Subproject commit a7579b8d1dff413f1dd588e53020ba734cc47c2e diff --git a/server/app.module.ts b/server/app.module.ts index 0b42ff7ed..dcaa9916d 100644 --- a/server/app.module.ts +++ b/server/app.module.ts @@ -60,6 +60,8 @@ import { ChatbotModule } from "./chat-bot/chat-bot.module"; import { VerificationRequestModule } from "./verification-request/verification-request.module"; import { FeatureFlagModule } from "./feature-flag/feature-flag.module"; import { GroupModule } from "./group/group.module"; +import { SessionOrM2MGuard } from "./auth/m2m-or-session.guard"; +import { M2MGuard } from "./auth/m2m.guard"; @Module({}) export class AppModule implements NestModule { @@ -165,14 +167,16 @@ export class AppModule implements NestModule { }, { provide: APP_GUARD, - useExisting: SessionGuard, + useExisting: SessionOrM2MGuard, }, { provide: APP_GUARD, useExisting: NameSpaceGuard, }, NameSpaceGuard, + SessionOrM2MGuard, SessionGuard, + M2MGuard, ], }; } diff --git a/server/auth/ability/abilities.guard.ts b/server/auth/ability/abilities.guard.ts index 9494929a3..338be4b75 100644 --- a/server/auth/ability/abilities.guard.ts +++ b/server/auth/ability/abilities.guard.ts @@ -5,19 +5,18 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; -import { Configuration, FrontendApi } from "@ory/client"; import { CHECK_ABILITY, RequiredRule } from "./ability.decorator"; import { AbilityFactory } from "./ability.factory"; import { NameSpaceEnum } from "../../auth/name-space/schemas/name-space.schema"; +import { User } from "../../entities/user.entity"; +import { M2M } from "../../entities/m2m.entity"; @Injectable() export class AbilitiesGuard implements CanActivate { constructor( private reflector: Reflector, - private caslAbilityFactor: AbilityFactory, - private configService: ConfigService + private caslAbilityFactor: AbilityFactory ) {} async canActivate(context: ExecutionContext): Promise { @@ -28,20 +27,17 @@ export class AbilitiesGuard implements CanActivate { ) || []; const request = context.switchToHttp().getRequest(); - const oryConfig = new Configuration({ - basePath: this.configService.get("ory.url"), - accessToken: this.configService.get("access_token"), - }); - const ory = new FrontendApi(oryConfig); - const { data: session } = await ory.toSession({ - cookie: request.header("Cookie"), - }); - const user = session.identity.traits; + const subject: User | M2M | undefined = request.user; + if (!subject) { + // Not authenticated + return false; + } const nameSpaceSlug = request.params.namespace || NameSpaceEnum.Main; const ability = this.caslAbilityFactor.defineAbility( - user, + subject, nameSpaceSlug ); + try { rules.forEach((rule) => ForbiddenError.from(ability).throwUnlessCan( @@ -55,6 +51,7 @@ export class AbilitiesGuard implements CanActivate { if (error instanceof ForbiddenError) { throw new UnauthorizedException(error.message); } + throw error; } } } diff --git a/server/auth/ability/ability.decorator.ts b/server/auth/ability/ability.decorator.ts index 9ba26dfdc..51ca05102 100644 --- a/server/auth/ability/ability.decorator.ts +++ b/server/auth/ability/ability.decorator.ts @@ -1,6 +1,7 @@ import { SetMetadata } from "@nestjs/common"; -import { User } from "../../enities/user.entity"; +import { User } from "../../entities/user.entity"; import { Action, Subjects } from "./ability.factory"; +import { M2M } from "../../entities/m2m.entity"; export interface RequiredRule { action: Action; @@ -26,3 +27,8 @@ export class AdminUserAbility implements RequiredRule { action = Action.Manage; subject = User; } + +export class IntegrationAbility implements RequiredRule { + action = Action.Create; + subject = M2M; +} diff --git a/server/auth/ability/ability.factory.ts b/server/auth/ability/ability.factory.ts index 24e69d05b..ab4f9561c 100644 --- a/server/auth/ability/ability.factory.ts +++ b/server/auth/ability/ability.factory.ts @@ -6,7 +6,8 @@ import { ExtractSubjectType, InferSubjects, } from "@casl/ability"; -import { User } from "../../enities/user.entity"; +import { User } from "../../entities/user.entity"; +import { M2M } from "../../entities/m2m.entity"; export enum Action { Manage = "manage", @@ -22,40 +23,46 @@ export enum Roles { Admin = "admin", //manage SuperAdmin = "super-admin", //Manage / Not editable Reviewer = "reviewer", // //read, create, update + Integration = "integration", } export enum Status { Inactive = "inactive", Active = "active", } -export type Subjects = InferSubjects | "all"; + +export type Subjects = InferSubjects | "all"; export type AppAbility = Ability<[Action, Subjects]>; @Injectable() export class AbilityFactory { - defineAbility(user: User, nameSpace: string) { + defineAbility(subject: User | M2M, nameSpace: string) { const { can, cannot, build } = new AbilityBuilder( Ability as AbilityClass ); - if ( - user.role[nameSpace] === Roles.Admin || - user.role[nameSpace] === Roles.SuperAdmin - ) { - can(Action.Manage, "all"); - } else if ( - user.role[nameSpace] === Roles.FactChecker || - user.role[nameSpace] === Roles.Reviewer - ) { - can(Action.Read, "all"); - can(Action.Update, "all"); + if (subject.isM2M && subject.role[nameSpace] === Roles.Integration) { can(Action.Create, "all"); } else { - can(Action.Read, "all"); - cannot(Action.Create, "all").because( - "special message: only admins!" - ); + if ( + subject.role[nameSpace] === Roles.Admin || + subject.role[nameSpace] === Roles.SuperAdmin + ) { + can(Action.Manage, "all"); + } else if ( + subject.role[nameSpace] === Roles.FactChecker || + subject.role[nameSpace] === Roles.Reviewer + ) { + can(Action.Read, "all"); + can(Action.Update, "all"); + can(Action.Create, "all"); + } else { + can(Action.Read, "all"); + cannot(Action.Create, "all").because( + "special message: only admins!" + ); + } } return build({ diff --git a/server/auth/base.guard.ts b/server/auth/base.guard.ts new file mode 100644 index 000000000..2b7e556cb --- /dev/null +++ b/server/auth/base.guard.ts @@ -0,0 +1,61 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "@nestjs/common"; +import { Configuration } from "@ory/client"; + +@Injectable() +export abstract class BaseGuard implements CanActivate { + protected logger = new Logger(BaseGuard.name); + protected oryConfig = new Configuration({ + basePath: this.configService.get("ory.url"), + accessToken: this.configService.get("ory.access_token"), + }); + + constructor( + protected configService: ConfigService, + protected readonly reflector: Reflector + ) {} + + // Implement this in child guards + abstract canActivate(context: ExecutionContext): Promise | boolean; + + /** + * Utility to extract a bearer token from Authorization header. + */ + protected extractBearerToken(authHeader?: string): string | null { + if (!authHeader) return null; + const matches = authHeader.match(/Bearer\s+(\S+)/); + return matches?.[1] || null; + } + + /** + * You can implement a shared redirect or fallback logic here. + */ + protected checkAndRedirect(request, response, isPublic, redirectPath) { + const isAllowedPublicUrl = [ + "/login", + "/unauthorized", + "/_next", + "/api/.ory", + "/api/health", + "/sign-up", + "/api/user/register", + "/api/claim", // Allow this route to be public temporarily for testing + ].some((route) => request.url.startsWith(route)); + + const overridePublicRoutes = + !isAllowedPublicUrl && + this.configService.get("override_public_routes"); + + if ( + (isPublic && !overridePublicRoutes) || + request.url.startsWith("/api") + ) { + return true; + } else { + response.redirect(redirectPath); + return false; + } + } +} diff --git a/server/auth/m2m-or-session.guard.ts b/server/auth/m2m-or-session.guard.ts new file mode 100644 index 000000000..a45f92212 --- /dev/null +++ b/server/auth/m2m-or-session.guard.ts @@ -0,0 +1,25 @@ +import { Injectable, ExecutionContext, CanActivate } from "@nestjs/common"; +import { SessionGuard } from "./session.guard"; +import { M2MGuard } from "./m2m.guard"; + +@Injectable() +export class SessionOrM2MGuard implements CanActivate { + constructor( + private readonly sessionGuard: SessionGuard, + private readonly m2mGuard: M2MGuard + ) {} + + async canActivate(context: ExecutionContext): Promise { + const m2mOk = await this.m2mGuard.canActivate(context); + if (m2mOk) { + return true; + } + + const sessionOk = await this.sessionGuard.canActivate(context); + if (sessionOk) { + return true; + } + + return false; + } +} diff --git a/server/auth/m2m.guard.ts b/server/auth/m2m.guard.ts new file mode 100644 index 000000000..2205a66f1 --- /dev/null +++ b/server/auth/m2m.guard.ts @@ -0,0 +1,53 @@ +import { Injectable, ExecutionContext, Logger } from "@nestjs/common"; +import { BaseGuard } from "./base.guard"; +import { Configuration, OAuth2Api } from "@ory/client"; + +@Injectable() +export class M2MGuard extends BaseGuard { + protected readonly logger = new Logger(M2MGuard.name); + + async canActivate(context: ExecutionContext): Promise { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + try { + const token = this.extractBearerToken( + request.headers["authorization"] + ); + if (!token) { + return false; + } + + const hydraBasePath = + this.configService.get("ory.hydra.url"); + const introspectionToken = + this.configService.get("access_token"); + + const hydraConfig = new Configuration({ + basePath: hydraBasePath, + accessToken: introspectionToken, + }); + const hydraApi = new OAuth2Api(hydraConfig); + + const { data } = await hydraApi.introspectOAuth2Token({ token }); + if (!data.active) { + return false; + } + + const isM2M = data.client_id && data.sub === data.client_id; + request.user = { + isM2M, + clientId: data.client_id, + subject: data.sub, + scopes: data.scope?.split(" "), + role: { main: "integration" }, + namespace: "main", + }; + + return true; + } catch (error) { + this.logger.error("M2M token validation failed", error); + return false; + } + } +} diff --git a/server/auth/session.guard.ts b/server/auth/session.guard.ts index 00b1d8526..90a63a3f7 100644 --- a/server/auth/session.guard.ts +++ b/server/auth/session.guard.ts @@ -1,19 +1,13 @@ -import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; import { Configuration, FrontendApi } from "@ory/client"; -import { Reflector } from "@nestjs/core"; -import { ConfigService } from "@nestjs/config"; import { Roles } from "./ability/ability.factory"; import { Logger } from "@nestjs/common"; +import { BaseGuard } from "./base.guard"; @Injectable() -export class SessionGuard implements CanActivate { +export class SessionGuard extends BaseGuard { private readonly logger = new Logger(SessionGuard.name); - constructor( - private configService: ConfigService, - private readonly reflector: Reflector - ) {} - // @ts-ignore async canActivate( context: ExecutionContext @@ -32,7 +26,8 @@ export class SessionGuard implements CanActivate { if (type === "ory") { const oryConfig = new Configuration({ basePath: this.configService.get("ory.url"), - accessToken: this.configService.get("access_token"), + accessToken: + this.configService.get("ory.access_token"), }); const ory = new FrontendApi(oryConfig); const { data: session } = await ory.toSession({ @@ -66,6 +61,7 @@ export class SessionGuard implements CanActivate { ); } request.user = { + isM2M: false, _id: session?.identity?.traits?.user_id, // Needed to enable feature flag for specific users id: session?.identity?.traits?.user_id, @@ -93,31 +89,4 @@ export class SessionGuard implements CanActivate { return this.checkAndRedirect(request, response, isPublic, "/login"); } } - - private checkAndRedirect(request, response, isPublic, redirectPath) { - const isAllowedPublicUrl = [ - "/login", - "/unauthorized", - "/_next", - "/api/.ory", - "/api/health", - "/sign-up", - "/api/user/register", - "/api/claim", // Allow this route to be public temporarily for testing - ].some((route) => request.url.startsWith(route)); - - const overridePublicRoutes = - !isAllowedPublicUrl && - this.configService.get("override_public_routes"); - - if ( - (isPublic && !overridePublicRoutes) || - request.url.startsWith("/api") - ) { - return true; - } else { - response.redirect(redirectPath); - return false; - } - } } diff --git a/server/entities/m2m.entity.ts b/server/entities/m2m.entity.ts new file mode 100644 index 000000000..cc7a62403 --- /dev/null +++ b/server/entities/m2m.entity.ts @@ -0,0 +1,7 @@ +export class M2M { + isM2M: boolean; + role: { + main: string; + }; + scopes: string[]; +} diff --git a/server/enities/user.entity.ts b/server/entities/user.entity.ts similarity index 75% rename from server/enities/user.entity.ts rename to server/entities/user.entity.ts index 6e51c2ff7..c01bae959 100644 --- a/server/enities/user.entity.ts +++ b/server/entities/user.entity.ts @@ -1,4 +1,5 @@ export class User { + isM2M: boolean; role: { main: string; };