presentation by
Edouard Maleix
Backend developer,
consultant and
trainer
Node.js | NestJS | Nx
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
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.
"For too long, authorization has been
viewed as an afterthought."
API designers and developers need
simple, efficient, cost-effective solution, based on open standards.
What is Ory?
Key components
Benefits
2014
2019
2023
2024
BIRTH
Ory Network creation
Ory Network Scale up
ISO 27K and SOC2
certified
Self-service API
Custom identity schema
Multi-Factor Authentication
Admin APIs
Webhooks
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
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
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
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
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
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
@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
@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
@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();
};
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
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="
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>
Let's create an end-to-end tests suite.
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);
};
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' });
};
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
Migration challenges
Manage environments
External resources
Moving from self-hosting to Ory Network.
type OryNetworkConfig = {
name: string;
services: {
identity: {
config: Record<string, unknown>;
};
permission: {
config: Record<string, unknown>;
};
};
};