Skip to content

Commit

Permalink
Merge pull request #1803 from AletheiaFact/1801-enhancement-support-m…
Browse files Browse the repository at this point in the history
…2m-authentication-for-external-integrations

 Implement M2M authentication using ory hydra
  • Loading branch information
thesocialdev authored Feb 12, 2025
2 parents 77579f1 + d81406a commit e6d5662
Show file tree
Hide file tree
Showing 17 changed files with 307 additions and 72 deletions.
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "ory_infra/hydra"]
path = ory_infra/hydra
url = https://github.com/ory/hydra.git
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions deployment/config/config-file/modules/main.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ hidden var = new {
}
ory = (oryConfig) {
admin_endpoint = "admin"
hydra = new {
admin_endpoint = "admin"
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions deployment/config/config-file/modules/ory.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
59 changes: 58 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
33 changes: 33 additions & 0 deletions ory_config/hydra.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions ory_infra/hydra
Submodule hydra added at a7579b
6 changes: 5 additions & 1 deletion server/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
],
};
}
Expand Down
25 changes: 11 additions & 14 deletions server/auth/ability/abilities.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand All @@ -28,20 +27,17 @@ export class AbilitiesGuard implements CanActivate {
) || [];

const request = context.switchToHttp().getRequest();
const oryConfig = new Configuration({
basePath: this.configService.get<string>("ory.url"),
accessToken: this.configService.get<string>("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(
Expand All @@ -55,6 +51,7 @@ export class AbilitiesGuard implements CanActivate {
if (error instanceof ForbiddenError) {
throw new UnauthorizedException(error.message);
}
throw error;
}
}
}
8 changes: 7 additions & 1 deletion server/auth/ability/ability.decorator.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,3 +27,8 @@ export class AdminUserAbility implements RequiredRule {
action = Action.Manage;
subject = User;
}

export class IntegrationAbility implements RequiredRule {
action = Action.Create;
subject = M2M;
}
43 changes: 25 additions & 18 deletions server/auth/ability/ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<typeof User> | "all";

export type Subjects = InferSubjects<typeof User | typeof M2M> | "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<AppAbility>
);

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({
Expand Down
61 changes: 61 additions & 0 deletions server/auth/base.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string>("ory.url"),
accessToken: this.configService.get<string>("ory.access_token"),
});

constructor(
protected configService: ConfigService,
protected readonly reflector: Reflector
) {}

// Implement this in child guards
abstract canActivate(context: ExecutionContext): Promise<boolean> | 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<string>("override_public_routes");

if (
(isPublic && !overridePublicRoutes) ||
request.url.startsWith("/api")
) {
return true;
} else {
response.redirect(redirectPath);
return false;
}
}
}
Loading

0 comments on commit e6d5662

Please sign in to comment.