AuthN and AuthZ strategies with 

NestJS and Ory

presentation by

Edouard Maleix

Backend developer,

 consultant and 

trainer
       
Node.js | NestJS | Nx

 

getlarge.eu

@e_maleix on X

@getlarge on 

Github

 Industry Insights and Facts

Ory Overview and Storyline

Design Phase for a Greenfield Project

Ory Setup works on my machine

NestJS Integration time to code

Demo Time and end-to-end testing

 

AGENDA

Do you know the difference between

Authentication and

Authorization

Industry Insights

According to IBM’s Cost of a Data Breach Report 2023, half of the companies surveyed plan to expand their budget after a security incident.

According to OWASP Top 10 from 2021, broken access control is the most common security vulnerability in web applications.

Why did this happen

"For too long, authorization has been

viewed as an afterthought."

 

 

API designers and developers need

simple, efficient, cost-effective solution, based on open standards.

Poll time

Ory Overview

 

 

What is Ory?

Key components

Benefits
 

What is Ory?

2014

2019

2023

2024

BIRTH

Ory Network creation

Ory Network Scale up

ISO 27K and SOC2

certified

Ory Core Components

  • Self-service API

  • Custom identity schema

  • Multi-Factor Authentication

  • Admin APIs

  • Webhooks

Registration flow

Login flow

Interact with backend

  • Declarative Models
  • Ory Permission Language (OPL)
  • Relations API
  • Permissions API

Example

Douglas can edit the Document JavaScript: The Good Parts

subject                  permission namespace   object

 

Document owners are a set of User

namespace        relationship                              namespace

Document owners can edit 

namespace        relationship          permission

User Douglas owns the Document JavaScript: The Good Parts

subject set             relationship  namespace    object

store a document

edit a document

 Summary 

Open-Source Foundation

Highly Customizable

Flexible deployment strategies

Developer-friendly

Straightforward migrations

Steep learning curve

Lack of complete tutorial

Missing a tool to make configuration files reusable

Community and Support

Design phase

What will we build?

Requirements gathering

Design considerations

User Authentication and Profile Management

User Roles and permissions

Cat Profiles Listing and Management

Fostering Matchmaking Requests

Key features

Architecture

Entities

  • User: Users can be cat guardians, fosters, or both. 

 

  • CatProfile: Represents the profiles of cats available for fostering. 

 

  • Fostering: an arrangement between a user (foster) and a cat. 
  • User: Users can be member of a Group (admin for instance)
     
  • CatProfile: Users in owners and from editors Group are allowed to edit. Owners cannot foster a Cat.

 

  • Fostering: CatProfile owners can approve and reject a request.
    FosterUsers can edit Fostering activities.
    Both CatProfile owners and fosterUsers can read Fostering activities.

Permissions

Ory setup

 

Local environment setup

Configuration reusability and replication

Tools: Docker & Docker Compose

services:
  # ...
  kratos:
    image: oryd/kratos:v1.1.0
    ports:
      - '4433:4433'
      - '4434:4434'
    command: serve -c /etc/config/kratos/kratos.yaml --dev --watch-courier
    volumes:
      - ./infra/ory-kratos:/etc/config/kratos:ro

  kratos-selfservice-ui-node:
    image: oryd/kratos-selfservice-ui-node:v1.0.0
    ports:
      - '4455:4455'

  keto:
    image: oryd/keto:v0.12.0
    ports:
      - '4466:4466' 
      - '4467:4467'
    command: serve -c /etc/config/keto/keto.yaml
    volumes:
      - ./infra/ory-keto:/etc/config/keto:ro

  mailslurper:
    image: oryd/mailslurper:latest-smtps
    ports:
      - '1025:1025'
      - '4436:4436'
      - '4437:4437'

Configure Ory Containers

some:
  nested_key:
    with_a_value: foo
    and_array:
      - id: foo
      - id: bar
export SOME_NESTED_KEY_WITH_A_VALUE=foo
export SOME_NESTED_KEY_AND_ARRAY_0_ID=foo
export SOME_NESTED_KEY_AND_ARRAY_1_ID=bar
some:
  nested_key:
    with_a_value: "##some_nested_key_with_a_value##"
    and_array: @@some_nested_key_with_a_value@@

Ory Configuration Templates

function keywordArrayReplace(input: string, mappings: KeywordMappings) {
  Object.keys(mappings).forEach(function (key) {
    const pattern = `@@${key}@@`;
    const patternWithQuotes = `"${pattern}"`;
    const regex = new RegExp(`${patternWithQuotes}|${pattern}`, 'g');
    input = input.replace(regex, JSON.stringify(mappings[key]));
  });
  return input;
}

function keywordStringReplace(input: string, mappings: KeywordMappings) {
  Object.keys(mappings).forEach(function (key) {
    const regex = new RegExp(`##${key}##`, 'g');
    const mapping = mappings[key];
    if (
      typeof mapping === 'string' ||
      typeof mapping === 'number' ||
      typeof mapping === 'boolean'
    ) {
      input = input.replace(regex, mapping.toString());
    } else {
      input = input.replace(regex, '');
    }
  });
  return input;
}

Ory Configuration Templates

cookies:
  domain: '##kratos_cookies_domain##'
  path: /
  same_site: Lax

courier:
  smtp:
    from_name: '##kratos_courier_smtp_from_name##'
    connection_uri: '##kratos_courier_smtp_connection_uri##'

dsn: '##kratos_dsn##'

feature_flags:
  cacheable_sessions: false
  use_continue_with_transitions: false

identity:
  default_schema_id: default
  schemas:
    - id: default
      url: '##kratos_identity_schemas_default##'

log:
  level: '##kratos_log_level##'
  leak_sensitive_values: true

oauth2_provider:
  override_return_to: true

organizations: []

preview:
  default_read_consistency_level: strong

secrets:
  cookie:
    - '##kratos_secrets_cookie##'
  cipher:
    - '##kratos_secrets_cipher##'

selfservice:
  allowed_return_urls: @@kratos_selfservice_allowed_return_urls@@
  default_browser_return_url: '##kratos_selfservice_default_browser_return_url##'
  flows:
    error:
      ui_url: '##kratos_selfservice_flows_errors_ui_url##'

    login:
      after:
        code:
          hooks: []
        hooks: []
        lookup_secret:
          hooks: []
        oidc:
          hooks: []
        password:
          hooks:
            - hook: web_hook
              config:
                method: '##kratos_selfservice_flows_login_after_hook_config_method##'
                auth:
                  type: api_key
                  config:
                    value: '##kratos_selfservice_flows_login_after_hook_config_auth_config_value##'
                    in: header
                    name: X-Ory-API-Key
                url: '##kratos_selfservice_flows_login_after_hook_config_url##'
                body: '##kratos_selfservice_flows_login_after_hook_config_body##'
                can_interrupt: ##kratos_selfservice_flows_login_after_hook_config_can_interrupt##
                response:
                  ignore: ##kratos_selfservice_flows_login_after_hook_config_response_ignore##
                  parse: ##kratos_selfservice_flows_login_after_hook_config_response_parse##
        totp:
          hooks: []
        webauthn:
          hooks: []
      before:
        hooks: []
      lifespan: 30m0s
      ui_url: '##kratos_selfservice_flows_login_ui_url##'

    logout:
      after:
        default_browser_return_url: '##kratos_selfservice_default_browser_return_url##'

    recovery:
      after:
        hooks: []
      before:
        hooks: []
      enabled: true
      lifespan: 30m0s
      notify_unknown_recipients: false
      ui_url: '##kratos_selfservice_flows_recovery_ui_url##'
      use: code

    registration:

      after:
        code:
          hooks: []
        hooks: []
        oidc:
          hooks:
          - hook: show_verification_ui
        password:
          hooks:
          - hook: web_hook
            config:
              method: '##kratos_selfservice_flows_registration_after_hook_config_method##'
              auth:
                type: api_key
                config:
                  value: '##kratos_selfservice_flows_registration_after_hook_config_auth_config_value##'
                  in: header
                  name: X-Ory-API-Key
              url: '##kratos_selfservice_flows_registration_after_hook_config_url##'
              body: '##kratos_selfservice_flows_registration_after_hook_config_body##'
              can_interrupt: ##kratos_selfservice_flows_registration_after_hook_config_can_interrupt##
              response:
                ignore: ##kratos_selfservice_flows_registration_after_hook_config_response_ignore##
                parse: ##kratos_selfservice_flows_registration_after_hook_config_response_parse##
          - hook: show_verification_ui
        webauthn:
          hooks:
          - hook: show_verification_ui
      before:
        hooks: []
      enabled: true
      lifespan: 30m0s
      login_hints: true
      ui_url: '##kratos_selfservice_flows_registration_ui_url##'

    settings:
      after:
        hooks: []
        lookup_secret:
          hooks: []
        oidc:
          hooks: []
        # passkey:
        #   hooks: []
        password:
          hooks: []
        profile:
          hooks: []
        totp:
          hooks: []
        webauthn:
          hooks: []
      before:
        hooks: []
      lifespan: 30m0s
      privileged_session_max_age: 15m0s
      required_aal: highest_available
      ui_url: '##kratos_selfservice_flows_settings_ui_url##'

    verification:
      after:
        default_browser_return_url: '##kratos_selfservice_default_browser_return_url##'
        hooks: []
      before:
        hooks: []
      enabled: true
      lifespan: 30m0s
      notify_unknown_recipients: false
      ui_url: '##kratos_selfservice_flows_verification_ui_url##'
      use: code

  methods:
    code:
      config:
        lifespan: 15m0s
      enabled: true
      mfa_enabled: false
      passwordless_enabled: false
    link:
      config:
        base_url: ""
        lifespan: 15m0s
      enabled: true
    lookup_secret:
      enabled: true
    oidc:
      config:
        providers: []
        enabled: false
    password:
      config:
        haveibeenpwned_enabled: true
        identifier_similarity_check_enabled: true
        ignore_network_errors: true
        max_breaches: 1
        min_password_length: 8
      enabled: true
    profile:
      enabled: true
    totp:
      config:
        issuer: CatFostering
      enabled: true
    webauthn:
      config:
        passwordless: false
        rp:
          display_name: CatFostering
          id: '##kratos_selfservice_methods_webauthn_config_rp_id##'
          origins: @@kratos_selfservice_methods_webauthn_config_rp_origins@@
      enabled: ##kratos_selfservice_methods_webauthn_enabled##

serve:
  admin:
    base_url: '##kratos_serve_admin_base_url##'
  public:
    base_url: '##kratos_serve_public_base_url##'
    cors:
      enabled: ##kratos_serve_public_cors_enabled##
      allowed_origins: @@kratos_serve_public_cors_allowed_origins@@
      allowed_methods:
        - POST
        - GET
        - PUT
        - PATCH
        - DELETE
      allowed_headers:
        - Authorization
        - Cookie
        - Content-Type
      exposed_headers:
        - Content-Type
        - Set-Cookie

session:
  cookie:
    domain: '##kratos_session_cookie_domain##'
    name: '##kratos_session_cookie_name##'
    path: /
    persistent: true
    same_site: Lax
  lifespan: 72h0m0s
  whoami:
    required_aal: highest_available
    tokenizer:
      templates: {}

version: v1.1.0

Kratos configuration

{
  "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
  "type": "object",
  "properties": {
    "traits": {
      "type": "object",
      "properties": {
        "email": {
          "type": "string",
          "format": "email",
          "title": "E-Mail",
          "minLength": 3,
          "ory.sh/kratos": {
            "credentials": {
              "password": {
                "identifier": true
              }
            },
            "verification": {
              "via": "email"
            },
            "recovery": {
              "via": "email"
            }
          }
        }
      },
      "required": ["email"],
      "additionalProperties": false
    }
  }
}

Kratos Identity Schema

function(ctx)
if std.startsWith(ctx.identity.traits.email, "test-") then
  error "cancel"
else
  {
    identity: ctx.identity,
  }

Kratos Webhooks Preprocessor

class Group implements Namespace {
  related: { members: User[]; };
}
class CatProfile implements Namespace {
  related: {
    owners: User[];
    editors: SubjectSet<Group, 'members'>[];
  };
  permits = {
    edit: (ctx: Context) =>
      this.related.owners.includes(ctx.subject) ||
      this.related.editors.includes(ctx.subject),
    foster: (ctx: Context) => !this.related.owners.includes(ctx.subject),
  };
}
class Fostering implements Namespace {
  related: {
    catProfiles: CatProfile[];
    fosterUsers: User[];
  };
  permits = {
    approve: (ctx: Context) =>
      this.related.catProfiles.traverse((cp) =>
        cp.related.owners.includes(ctx.subject)
      ),
    reject: (ctx: Context) => this.permits.approve(ctx),
    edit: (ctx: Context) => this.related.fosterUsers.includes(ctx.subject),
    read: (ctx: Context) => this.permits.approve(ctx) || this.permits.edit(ctx),
  };
}

Keto Permissions

 

Configuring NestJS application

Ory Webhooks handling

User registration

Relationships declaration

Routes protection

NestJS Integration

export class EnvironmentVariables {
  // ...
  @Expose()
  @IsUrl(urlOptions)
  ORY_KETO_ADMIN_URL?: string = 'http://localhost:4467';

  @Expose()
  @IsUrl(urlOptions)
  ORY_KETO_PUBLIC_URL?: string = 'http://localhost:4466';

  @SecretValue({ isOptional: true })
  ORY_KETO_API_KEY?: string = null;

  @Expose()
  @IsUrl(urlOptions)
  @IsOptional()
  ORY_KRATOS_ADMIN_URL?: string = 'http://localhost:4434';

  @Expose()
  @IsUrl(urlOptions)
  @IsOptional()
  ORY_KRATOS_PUBLIC_URL?: string = 'http://localhost:4433';

  @SecretValue({ isOptional: true })
  ORY_KRATOS_API_KEY?: string = null;

  @SecretValue()
  ORY_ACTION_API_KEY: string;
}

Configure NestJS application

Ory Kratos is configured to send 2 types of events to our application:

After registration

After login

 

Handle Webhooks

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @UseGuards(OryActionGuard)
  @UsePipes(
    new ValidationPipe({
      transform: true,
      forbidUnknownValues: true,
      exceptionFactory: (errors) => {
        const formattedErrors = validationErrorsToOryErrorMessages(errors);
        return new OryWebhookError(
          'Failed to validate input',
          formattedErrors,
          400
        );
      },
    })
  )
  @Post('on-sign-up')
  @HttpCode(HttpStatus.OK)
  onSignUp(@Body() body: OnOrySignUpDto): Promise<OnOrySignUpDto> {
    return this.usersService.onSignUp(body);
  }
//...
}

After registration

@Injectable()
export class OryActionGuard implements CanActivate {
  readonly logger = new Logger(OryActionGuard.name);
  private readonly apiKey: string;

  constructor(
    @Inject(ConfigService)
    configService: ConfigService<{ ORY_ACTION_API_KEY: string }, true>
  ) {
    this.apiKey = configService.get('ORY_ACTION_API_KEY');
  }

  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const authHeader = req.headers['x-ory-api-key'];
    return (
      !!authHeader &&
      timingSafeEqual(Buffer.from(authHeader), Buffer.from(this.apiKey))
    );
  }
}

Ory Action Guard

import { HttpException } from '@nestjs/common';

export type OryWebhookErrorMessages = {
  instance_ptr?: string;
  messages: {
    id: number;
    text: string;
    type: string;
    context?: {
      value: string;
    };
  }[];
};

export class OryWebhookError extends HttpException {
  constructor(
    message: string,
    public readonly messages: OryWebhookErrorMessages[],
    status: number
  ) {
    super(OryWebhookError.toJSON(messages, status), status);
    this.name = 'OryWebhookError';
    this.message = message;
    Error.captureStackTrace(this, OryWebhookError);
  }

  static toJSON(messages: OryWebhookErrorMessages[], statusCode: number) {
    return {
      statusCode,
      messages,
    };
  }
}

Ory Webhook Error

Custom form errors

export class UsersService {
  async onSignUp(body: OnOrySignUpDto): Promise<OnOrySignUpDto> {
    this.logger.debug(inspect({ method: 'onSignUp', body }, false, null, true));
    const { email } = body.identity.traits;
    const existingUser = await this.userRepository.findOne({
      where: { email },
    });
    if (existingUser) {
      throw new OryWebhookError(
        'email already used',
        [
          {
            instance_ptr: '#/traits/email',
            messages: [
              {
                id: 123,
                text: 'invalid email address',
                type: 'validation',
                context: {
                  value: email,
                },
              },
            ],
          },
        ],
        HttpStatus.BAD_REQUEST
      );
    }
    const result = await this.userRepository.save({ email });
    body.identity.metadata_public = { id: result.id };
    return { identity: body.identity };
  }
// ...
}

Ory identity mutation

Verify user identity

@Controller('users')
export class UsersController {
  @UseGuards(
    OryAuthenticationGuard({
      cookieResolver: (ctx) =>
        ctx.switchToHttp().getRequest<Request>().headers.cookie ?? '',
      sessionTokenResolver: (ctx) =>
        ctx
          .switchToHttp()
          .getRequest<Request>()
          .headers?.authorization?.replace('Bearer ', '') ?? '',
      postValidationHook: (ctx, session) => {
        if (!isValidOrySession(session)) {
          throw new HttpException('Invalid session', HttpStatus.UNAUTHORIZED);
        }
        const request = ctx.switchToHttp().getRequest();
        request.user = {
          id: session.identity.metadata_public['id'],
          email: session.identity.traits.email,
          identityId: session.identity.id,
        };
      },
    })
  )
  @Get('current-user')
  getCurrentUser(@CurrentUser() user: User): User {
    return user;
  }
}

Check user's session

Create relationships

@Controller('cat-profiles')
export class CatProfilesController {
  constructor(private readonly catProfilesService: CatProfilesService) {}

  // ...
  @UseGuards(AuthenticationGuard())
  @UsePipes(
    new ValidationPipe({
      transform: true,
      transformOptions: { enableImplicitConversion: true },
    })
  )
  @Post()
  create(
    @CurrentUser() user: CurrentUser,
    @Body() body: CreateCatProfile
  ): Promise<CatProfile> {
    return this.catProfilesService.create(body, user.id);
  }
//...
}

Create a Cat Profile - Controller

export class CatProfilesService {
   // ...
   async create(body: CreateCatProfile, userId: string) {
    const queryRunner =
      this.catProfileRepository.manager.connection.createQueryRunner();
    let catProfile = queryRunner.manager.create(CatProfileSchema, {
      ...body,
      owner: { id: userId },
    });
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      catProfile = await queryRunner.manager.save(catProfile);
      await this.createAdminRelationship(catProfile.id);
      await this.createOwnerRelationship(catProfile.id, userId);
      await queryRunner.commitTransaction();
      return catProfile;
    } catch (err) {
      this.logger.error(err);
      await queryRunner.rollbackTransaction();
      if (catProfile.id) {
        await this.deleteOwnerRelationship(catProfile.id, userId);
        await this.deleteAdminRelationship(catProfile.id);
      }
      throw new HttpException(
        'Failed to create cat profile',
        HttpStatus.BAD_REQUEST
      );
    } finally {
      await queryRunner.release();
    }
  }
  //...
}

Create a Cat Profile - Service

Check permissions

@Controller('cat-profiles')
export class CatProfilesController {
  constructor(private readonly catProfilesService: CatProfilesService) {}

  @OryPermissionChecks({
    type: 'OR',
    conditions: [isOwnerPermission, isAdminPermission],
  })
  @UseGuards(
    AuthenticationGuard(),
    OryAuthorizationGuard({
      unauthorizedFactory(ctx) {
        return new HttpException(
          'Forbidden',
          403,
          ctx.switchToHttp().getRequest().url
        );
      },
    })
  )
  @UsePipes(
    new ValidationPipe({
      transform: true,
      transformOptions: { enableImplicitConversion: true },
    })
  )
  @Patch(':id')
  updateById(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() body: UpdateCatProfile
  ): Promise<CatProfile> {
    return this.catProfilesService.updateById(id, body);
  }

Update a Cat Profile - Controller

import { CurrentUser, getCurrentUser } from '@cat-fostering/nestjs-utils';
import { relationTupleBuilder } from '@getlarge/keto-relations-parser';
import type { ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';

// returns Group:admin#members@User:<currentUserId>
export const isAdminPermission = (ctx: ExecutionContext): string => {
  const req = ctx.switchToHttp().getRequest<Request & { user: CurrentUser }>();
  const currentUserId = getCurrentUser(req).id;
  return relationTupleBuilder()
    .subject('User', currentUserId)
    .isIn('members')
    .of('Group', 'admin')
    .toString();
};

// returns CatProfile:<catProfileId>#owners@User:<currentUserId> 
export const isOwnerPermission = (ctx: ExecutionContext): string => {
  const req = ctx.switchToHttp().getRequest<Request & { user: CurrentUser }>();
  const currentUserId = getCurrentUser(req).id;
  const catProfileId = req.params['id'];
  return relationTupleBuilder()
    .subject('User', currentUserId)
    .isIn('owners')
    .of('CatProfile', catProfileId)
    .toString();
};

Evalute permission from request context

Time for some action

 

Setting up the project

Create users and admin

Interact with CatProfiles

Test automation

nano .env
nano apps/cat-fostering-api/.env
npm run ory:generate
npm run docker:dev:up
npx nx run cat-fostering-api:serve

Setup steps

BACKEND_DOCKER_HOST="http://host.docker.internal:3000"
BACKEND_HOST="http://127.0.0.1:3000"
KRATOS_PUBLIC_HOST="http://127.0.0.1:4433"
SELF_SERVICE_UI_HOST="http://127.0.0.1:4455"
ORY_ACTION_API_KEY="fetfek-fizNow-guqby0"
kratos_cookies_domain="127.0.0.1"
kratos_courier_smtp_connection_uri="smtps://test:test@mailslurper:1025/?skip_ssl_verify=true"
kratos_dsn="postgres://dbuser:secret@kratos-postgres:5432/kratosdb?sslmode=disable"
kratos_identity_schemas_default="base64://ewogICIkaWQiOiAiaHR0cHM6Ly9zY2hlbWFzLm9yeS5zaC9wcmVzZXRzL2tyYXRvcy9xdWlja3N0YXJ0L2VtYWlsLXBhc3N3b3JkL2lkZW50aXR5LnNjaGVtYS5qc29uIiwKICAiJHNjaGVtYSI6ICJodHRwOi8vanNvbi1zY2hlbWEub3JnL2RyYWZ0LTA3L3NjaGVtYSMiLAogICJ0aXRsZSI6ICJQZXJzb24iLAogICJ0eXBlIjogIm9iamVjdCIsCiAgInByb3BlcnRpZXMiOiB7CiAgICAidHJhaXRzIjogewogICAgICAidHlwZSI6ICJvYmplY3QiLAogICAgICAicHJvcGVydGllcyI6IHsKICAgICAgICAiZW1haWwiOiB7CiAgICAgICAgICAidHlwZSI6ICJzdHJpbmciLAogICAgICAgICAgImZvcm1hdCI6ICJlbWFpbCIsCiAgICAgICAgICAidGl0bGUiOiAiRS1NYWlsIiwKICAgICAgICAgICJtaW5MZW5ndGgiOiAzLAogICAgICAgICAgIm9yeS5zaC9rcmF0b3MiOiB7CiAgICAgICAgICAgICJjcmVkZW50aWFscyI6IHsKICAgICAgICAgICAgICAicGFzc3dvcmQiOiB7CiAgICAgICAgICAgICAgICAiaWRlbnRpZmllciI6IHRydWUKICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0sCiAgICAgICAgICAgICJ2ZXJpZmljYXRpb24iOiB7CiAgICAgICAgICAgICAgInZpYSI6ICJlbWFpbCIKICAgICAgICAgICAgfSwKICAgICAgICAgICAgInJlY292ZXJ5IjogewogICAgICAgICAgICAgICJ2aWEiOiAiZW1haWwiCiAgICAgICAgICAgIH0KICAgICAgICAgIH0KICAgICAgICB9CiAgICAgIH0sCiAgICAgICJyZXF1aXJlZCI6IFsiZW1haWwiXSwKICAgICAgImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjogZmFsc2UKICAgIH0KICB9Cn0K"
kratos_log_level="trace"
kratos_selfservice_allowed_return_urls="${BACKEND_HOST}, ${SELF_SERVICE_UI_HOST}"
kratos_selfservice_default_browser_return_url="${SELF_SERVICE_UI_HOST}/"
kratos_selfservice_flows_errors_ui_url="${SELF_SERVICE_UI_HOST}/error"
kratos_selfservice_flows_login_after_hook_config_url="${BACKEND_DOCKER_HOST}/api/users/on-sign-in"
kratos_selfservice_flows_login_after_hook_config_auth_config_value="${ORY_ACTION_API_KEY}"
kratos_selfservice_flows_login_after_hook_config_body="base64://ZnVuY3Rpb24oY3R4KQppZiBzdGQuc3RhcnRzV2l0aChjdHguaWRlbnRpdHkudHJhaXRzLmVtYWlsLCAidGVzdC0iKSB0aGVuCiAgZXJyb3IgImNhbmNlbCIKZWxzZQogIHsKICAgIGlkZW50aXR5OiBjdHguaWRlbnRpdHksCiAgfQo="
kratos_selfservice_flows_login_after_hook_config_can_interrupt="true"
kratos_selfservice_flows_login_after_hook_config_response_ignore="false"
kratos_selfservice_flows_login_after_hook_config_response_parse="false"
kratos_selfservice_flows_login_ui_url="${SELF_SERVICE_UI_HOST}/login"
kratos_selfservice_flows_recovery_ui_url="${SELF_SERVICE_UI_HOST}/recovery"
kratos_selfservice_flows_registration_after_hook_config_url="${BACKEND_DOCKER_HOST}/api/users/on-sign-up"
kratos_selfservice_flows_registration_after_hook_config_auth_config_value="${ORY_ACTION_API_KEY}"
kratos_selfservice_flows_registration_after_hook_config_body="base64://ZnVuY3Rpb24oY3R4KQppZiBzdGQuc3RhcnRzV2l0aChjdHguaWRlbnRpdHkudHJhaXRzLmVtYWlsLCAidGVzdC0iKSB0aGVuCiAgZXJyb3IgImNhbmNlbCIKZWxzZQogIHsKICAgIGlkZW50aXR5OiBjdHguaWRlbnRpdHksCiAgfQo="
kratos_selfservice_flows_registration_after_hook_config_can_interrupt="true"
kratos_selfservice_flows_registration_after_hook_config_response_ignore="false"
kratos_selfservice_flows_registration_after_hook_config_response_parse="true"
kratos_selfservice_flows_registration_ui_url="${SELF_SERVICE_UI_HOST}/registration"
kratos_selfservice_flows_settings_ui_url="${SELF_SERVICE_UI_HOST}/settings"
kratos_selfservice_flows_verification_ui_url="${SELF_SERVICE_UI_HOST}/verification"
kratos_selfservice_methods_passkey_config_rp_id="localhost"
kratos_selfservice_methods_passkey_config_rp_origins="https://localhost:8080"
kratos_selfservice_methods_passkey_enabled="false"
kratos_selfservice_methods_webauthn_config_rp_id="localhost"
kratos_selfservice_methods_webauthn_config_rp_origins="https://localhost:8080"
kratos_selfservice_methods_webauthn_enabled="false"
kratos_secrets_cookie="cookie_secret_not_good_not_secure"
kratos_secrets_cipher="32-LONG-SECRET-NOT-SECURE-AT-ALL"
kratos_serve_admin_base_url="http://kratos:4434/"
kratos_serve_public_base_url="${KRATOS_PUBLIC_HOST}/"
kratos_serve_public_cors_enabled="true"
kratos_serve_public_cors_allowed_origins="${KRATOS_PUBLIC_HOST}, ${SELF_SERVICE_UI_HOST}, ${BACKEND_HOST}"
kratos_session_cookie_domain="127.0.0.1"
keto_dsn="postgres://dbuser:secret@keto-postgres:5432/accesscontroldb?sslmode=disable"
keto_log_level="trace"
keto_namespaces_location="base64://aW1wb3J0IHR5cGUgewogIE5hbWVzcGFjZSwKICBDb250ZXh0LAogIFN1YmplY3RTZXQsCiAgLy8gQHRzLWV4cGVjdC1lcnJvciAtIFRoaXMgaXMgYSBwcml2YXRlIHR5cGUgZnJvbSB0aGUgaW50ZXJuYWwgT3J5IEtldG8gU0RLCn0gZnJvbSAnQG9yeS9wZXJtaXNzaW9uLW5hbWVzcGFjZS10eXBlcyc7CgpjbGFzcyBVc2VyIGltcGxlbWVudHMgTmFtZXNwYWNlIHt9CgpjbGFzcyBHcm91cCBpbXBsZW1lbnRzIE5hbWVzcGFjZSB7CiAgcmVsYXRlZDogewogICAgbWVtYmVyczogVXNlcltdOwogIH07Cn0KCmNsYXNzIENhdFByb2ZpbGUgaW1wbGVtZW50cyBOYW1lc3BhY2UgewogIHJlbGF0ZWQ6IHsKICAgIG93bmVyczogVXNlcltdOwogICAgZWRpdG9yczogU3ViamVjdFNldDxHcm91cCwgJ21lbWJlcnMnPltdOwogIH07CgogIHBlcm1pdHMgPSB7CiAgICBlZGl0OiAoY3R4OiBDb250ZXh0KSA9PgogICAgICB0aGlzLnJlbGF0ZWQub3duZXJzLmluY2x1ZGVzKGN0eC5zdWJqZWN0KSB8fAogICAgICB0aGlzLnJlbGF0ZWQuZWRpdG9ycy5pbmNsdWRlcyhjdHguc3ViamVjdCksCiAgICBmb3N0ZXI6IChjdHg6IENvbnRleHQpID0+ICF0aGlzLnJlbGF0ZWQub3duZXJzLmluY2x1ZGVzKGN0eC5zdWJqZWN0KSwKICB9Owp9CgpjbGFzcyBGb3N0ZXJpbmcgaW1wbGVtZW50cyBOYW1lc3BhY2UgewogIHJlbGF0ZWQ6IHsKICAgIGNhdFByb2ZpbGVzOiBDYXRQcm9maWxlW107CiAgICBmb3N0ZXJVc2VyczogVXNlcltdOwogIH07CgogIHBlcm1pdHMgPSB7CiAgICBhcHByb3ZlOiAoY3R4OiBDb250ZXh0KSA9PgogICAgICAvLyBAdHMtZXhwZWN0LWVycm9yIC0gVGhpcyBpcyBhIHByaXZhdGUgdHlwZSBmcm9tIHRoZSBpbnRlcm5hbCBPcnkgS2V0byBTREsKICAgICAgdGhpcy5yZWxhdGVkLmNhdFByb2ZpbGVzLnRyYXZlcnNlKChjcCkgPT4KICAgICAgICBjcC5yZWxhdGVkLm93bmVycy5pbmNsdWRlcyhjdHguc3ViamVjdCkKICAgICAgKSwKICAgIHJlamVjdDogKGN0eDogQ29udGV4dCkgPT4gdGhpcy5wZXJtaXRzLmFwcHJvdmUoY3R4KSwKICAgIGVkaXQ6IChjdHg6IENvbnRleHQpID0+IHRoaXMucmVsYXRlZC5mb3N0ZXJVc2Vycy5pbmNsdWRlcyhjdHguc3ViamVjdCksCiAgICByZWFkOiAoY3R4OiBDb250ZXh0KSA9PiB0aGlzLnBlcm1pdHMuYXBwcm92ZShjdHgpIHx8IHRoaXMucGVybWl0cy5lZGl0KGN0eCksCiAgfTsKfQo="

Environment variables

Self-Service UI

npx @getlarge/kratos-cli register --email admin@getlarge.eu
npx @getlarge/kratos-cli register --email nobody@getlarge.eu

# verify email addresses

npx @getlarge/kratos-cli login --email admin@getlarge.eu

export ORY_SESSION_TOKEN=<token>
curl  -X GET \                                                  
  'http://localhost:3000/api/users/current-user' \
  --header "Authorization: Bearer $ORY_SESSION_TOKEN"
  
npx @getlarge/keto-cli create --tuple Group:admin#members@User:<id>

Create an Admin user

Create an Admin user

Testing the CatProfiles API

Testing the Fostering API

Let's create an end-to-end tests suite.

 

  • Quick adaptation of Ory configurations for testing.
  • Use transient data only
  • Simplify user creation by turning off email verification.
  • Use Jest hooks to init and clean Ory services and DB

Automated Testing

import { execSync } from 'node:child_process';
import { createTestConnection } from './helpers';

const envPath = 'apps/cat-fostering-api/.env.test';
const cwd = process.cwd();

export default async (): Promise<void> => {
  console.log('\nSetting up...\n');

  execSync(
    'npx ts-node --project tools/tsconfig.json tools/ory/generate-config.ts keto -e .env.ci',
    { cwd, stdio: 'ignore' }
  );
  execSync('docker compose restart keto', { cwd, stdio: 'ignore' });
  execSync(
    'npx ts-node --project tools/tsconfig.json tools/ory/generate-config.ts kratos -e .env.ci',
    { cwd, stdio: 'ignore' }
  );
  execSync('docker compose restart kratos', { cwd, stdio: 'ignore' });

  globalThis.__DB_CONNECTION__ = await createTestConnection(envPath);
};

Jest Setup

import { execSync } from 'node:child_process';
import { DataSource } from 'typeorm';

const cwd = process.cwd();

export default async (): Promise<void> => {
  console.log('Cleaning up...');

  await (globalThis.__DB_CONNECTION__ as DataSource)?.destroy();

  execSync(
    'npx ts-node --project tools/tsconfig.json tools/ory/generate-config.ts keto -e .env',
    { cwd, stdio: 'ignore' }
  );
  execSync('docker compose restart keto', { cwd, stdio: 'ignore' });
  execSync(
    'npx ts-node --project tools/tsconfig.json tools/ory/generate-config.ts kratos -e .env',
    { cwd, stdio: 'ignore' }
  );
  execSync('docker compose restart kratos', { cwd, stdio: 'ignore' });
};

Jest Teardown

describe('E2E API tests', () => {
  let user1: TestUser;
  let user2: TestUser;

  beforeAll(async () => {
    user1 = await createOryUser({
      email: 'admin@test.it',
      password: 'p4s$worD!',
    });
    createOryAdminRelation({ userId: user1.id });
    user2 = await createOryUser({
      email: 'user@test.it',
      password: 'p4s$worD!',
    });
  });

  describe('GET /api/users/current-user', () => {
    it('should return the current user', async () => {
      const res = await axios.get(`/api/users/current-user`, {
        headers: {
          Authorization: `Bearer ${user1.sessionToken}`,
        },
      });
      expect(res.status).toBe(200);
      expect(res.data.email).toBe(user1.email);
    });

    it('should return 401 if no token is provided', async () => {
      const res = await axios.get(`/api/users/current-user`);
      expect(res.status).toBe(401);
    });
  });
  
  describe('POST /api/cat-profiles', () => {
    it('should create a cat profile', async () => {
      const res = await axios.post(
        `/api/cat-profiles`,
        {
          name: 'Godard',
          description: 'Black and white cat, knows how to open doors',
          age: 3,
        },
        axiosOptionsFactory(user1.sessionToken)
      );
      expect(res.status).toBe(201);
    });
  });

  describe('PATCH /api/cat-profiles/:id', () => {
    it('should update a cat profile when user is the owner', async () => {
      const cat = await createCat({
        name: 'Romeo',
        description: 'Grey cat, loves to cuddle',
        age: 2,
        sessionToken: user1.sessionToken,
      });

      const res = await axios.patch(
        `/api/cat-profiles/${cat.id}`,
        { age: 3 },
        axiosOptionsFactory(user1.sessionToken)
      );
      expect(res.status).toBe(200);
    });

    it('should update a cat profile when user is an admin', async () => {
      const cat = await createCat({
        name: 'Juliet',
        description: 'White cat, loves to play',
        age: 1,
        sessionToken: user2.sessionToken,
      });

      const res = await axios.patch(
        `/api/cat-profiles/${cat.id}`,
        { age: 2 },
        axiosOptionsFactory(user1.sessionToken)
      );
      expect(res.status).toBe(200);
    });

    it(`should return 403 if the user is not an admin or the cat profile's owner`, async () => {
      const cat = await createCat({
        name: 'Crousti',
        description: 'Tabby brown, with a diva attitude',
        age: 8,
        sessionToken: user1.sessionToken,
      });

      const res = await axios.patch(
        `/api/cat-profiles/${cat.id}`,
        { age: 9 },
        axiosOptionsFactory(user2.sessionToken)
      );
      expect(res.status).toBe(403);
    });
  });
})

Write E2E tests

Run the tests

Conclusion

 

Migration challenges

Manage environments

External resources

 

  • Users migration
  • Permissions migrations
     

Migration Challenges

Users migration

  • Assess the user pool data (access to password hashes?)
  • Map the existing user attributes to the corresponding fields in the Ory Kratos identity schema
  • Import the user accounts using the Identity API
  • Monitor the user migration for any issues and resolve them as needed.

Permissions migrations

  • Identify a permission
  • Setup automated end-to-end tests
  • Map the existing permission to the new policy set in Oro Keto
  • Test the permissions mapping
  • Deploy the migration to the production
  • Repeat the process for all the existing policies

Manage multiple environments

  • Stick with Ory self-hosting for local development and testing
  • Move to Ory Network for staging and production
  • Synchronize and version environment variables in a vault such as Dotenv.org

Resources

getlarge.eu

@e_maleix on X

@getlarge on 

Github

Deploy to Ory Network

Moving from self-hosting to Ory Network.

  • Benefits of Ory Network
  • Transition steps

Ory Network

  • Global Multi-Region Architecture
  • Commitment to Compliance
  • Reliability
  • Tenant Management
  • Configure self-service UI

Bridging the Gap Between Open-Source and Cloud

Transition steps

  • We start by registering an account with Ory Network.
  • Create a new workspace to organize your projects.
  • Register a new project for each stage
  • Install the Ory CLI to configure your project
  • Create script to generate project configuration from existing template files

Build tools to configure the project

  • File references pointing to local files (file:// protocol) won't work
  • URLs must resolve to public addresses.
  • Keys for server configuration are ignored
type OryNetworkConfig = {
  name: string;
  services: {
    identity: {
      config: Record<string, unknown>;
    };
    permission: {
      config: Record<string, unknown>;
    };
  };
};

Transition steps

AuthN and AuthZ in NestJS with Ory

By edouard_maleix

AuthN and AuthZ in NestJS with Ory

According to OWASP 10, broken access control is web applications' most common security vulnerability. In this context, it is more crucial than ever for (NestJS) developers to be empowered with solutions that enhance their application's security without sacrificing efficiency. This presentation focuses on integrating Ory, an open-source identity and access control management toolkit, with NestJS to provide a complete security solution.

  • 379