diff --git a/graphql/auth/auth.edges.graphql b/graphql/auth/auth.edges.graphql new file mode 100644 index 0000000..73af7a1 --- /dev/null +++ b/graphql/auth/auth.edges.graphql @@ -0,0 +1,9 @@ +type AuthOauth2ClientToAuthRealmEdge { + data: AuthRealm + error: AuthOauth2ClientToAuthRealmError +} + +type AuthOauth2ClientToAuthOauth2ScopesEdge { + data: [AuthOauth2Scope] + error: AuthOauth2ClientToAuthOauth2ScopesError +} diff --git a/graphql/auth/auth.enumerations.graphql b/graphql/auth/auth.enumerations.graphql new file mode 100644 index 0000000..1aacf18 --- /dev/null +++ b/graphql/auth/auth.enumerations.graphql @@ -0,0 +1,4 @@ +enum AuthOauth2ClientTypeEnum { + CONFIDENTIAL + PUBLIC +} diff --git a/graphql/auth/auth.errors.graphql b/graphql/auth/auth.errors.graphql new file mode 100644 index 0000000..9580ef0 --- /dev/null +++ b/graphql/auth/auth.errors.graphql @@ -0,0 +1,7 @@ +enum AuthOauth2ClientToAuthRealmError { + UNKNOWN +} + +enum AuthOauth2ClientToAuthOauth2ScopesError { + UNKNOWN +} diff --git a/graphql/auth/auth.types.graphql b/graphql/auth/auth.types.graphql new file mode 100644 index 0000000..7682366 --- /dev/null +++ b/graphql/auth/auth.types.graphql @@ -0,0 +1,20 @@ +type AuthRealm { + urn: ID! + name: String + createdAt: String +} + +type AuthOauth2Client { + urn: ID! + clientId: String! + clientType: AuthOauth2ClientTypeEnum! + clientSecret: String + + Realm: AuthOauth2ClientToAuthRealmEdge + Scopes: AuthOauth2ClientToAuthOauth2Scopes +} + +type AuthOauth2Scope { + urn: ID! + scope: String +} diff --git a/graphql/identity/identity.enumerations.graphql b/graphql/identity/identity.enumerations.graphql index 4182f41..a1a0fce 100644 --- a/graphql/identity/identity.enumerations.graphql +++ b/graphql/identity/identity.enumerations.graphql @@ -1,3 +1,10 @@ enum IdentityAuthDeviceTypeEnum { PASSWORD + APPLICATION_PASSWORD +} + +enum IdentityGroupRoleEnum { + SYSTEM_ADMIN + REALM_ADMIN + STANDARD } diff --git a/graphql/identity/identity.queries.graphql b/graphql/identity/identity.queries.graphql new file mode 100644 index 0000000..b510ade --- /dev/null +++ b/graphql/identity/identity.queries.graphql @@ -0,0 +1,10 @@ +type IdentityUserOutput { + error: + data: IdentityUser +} + +type Query { + identityUsers() + identityUser(urn: String!): IdentityUserOutput! + myUser +} \ No newline at end of file diff --git a/graphql/identity/identity.types.graphql b/graphql/identity/identity.types.graphql index 0caa5ce..091935b 100644 --- a/graphql/identity/identity.types.graphql +++ b/graphql/identity/identity.types.graphql @@ -1,6 +1,7 @@ type IdentityGroup { urn: ID! isAdmin: Boolean! + role: IdentityGroupRoleEnum! name: String Users: IdentityGroupToIdentityUserEdge! diff --git a/services/core/package-lock.json b/services/core/package-lock.json index ee85122..bd85b3f 100644 --- a/services/core/package-lock.json +++ b/services/core/package-lock.json @@ -11,14 +11,16 @@ "dependencies": { "@apollo/server": "^4.10.2", "@nestjs/apollo": "^12.1.0", + "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.1.1", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.12.1", "bcrypt": "^5.1.1", + "cache-manager": "^5.5.1", "graphql": "^16.8.1", - "joi": "^17.12.3", + "joi": "17.6.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -4315,6 +4317,17 @@ } } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz", + "integrity": "sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz", @@ -6393,6 +6406,30 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.5.1.tgz", + "integrity": "sha512-QYZFOjZTTennYdN3NNCKh+yq452+wQ4ChyL40jkEyghIgg5Ugwb4YO8ARIIF1fvTBkgDLlLTYFaxZVaPGmQ92A==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.0", + "promise-coalesce": "^1.1.2" + } + }, + "node_modules/cache-manager/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -9868,14 +9905,14 @@ } }, "node_modules/joi": { - "version": "17.12.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.3.tgz", - "integrity": "sha512-2RRziagf555owrm9IRVtdKynOBeITiDpuZqIpgwqXShPncPKNiRQoiGsl/T8SQdq+8ugRzH2LqY67irr2y/d+g==", + "version": "17.6.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.4.tgz", + "integrity": "sha512-tPzkTJHZQjSFCc842QpdVpOZ9LI2txApboNUbW70qgnRB14Lzl+oWQOPdF2N4yqyiY14wBGe8lc7f/2hZxbGmw==", "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.0", "@sideway/pinpoint": "^2.0.0" } }, @@ -10137,6 +10174,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -11201,6 +11243,14 @@ "asap": "~2.0.3" } }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "engines": { + "node": ">=16" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/services/core/package.json b/services/core/package.json index 4d4340d..324d3c5 100644 --- a/services/core/package.json +++ b/services/core/package.json @@ -25,14 +25,16 @@ "dependencies": { "@apollo/server": "^4.10.2", "@nestjs/apollo": "^12.1.0", + "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.1.1", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^5.12.1", "bcrypt": "^5.1.1", + "cache-manager": "^5.5.1", "graphql": "^16.8.1", - "joi": "^17.12.3", + "joi": "17.6.4", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, diff --git a/services/core/prisma/schema.prisma b/services/core/prisma/schema.prisma index a7a4554..7c4edcf 100644 --- a/services/core/prisma/schema.prisma +++ b/services/core/prisma/schema.prisma @@ -20,13 +20,102 @@ model SystemPostMigration { createdAt DateTime @default(now()) } +// +// Namespace: Auth +// +model AuthRealm { + id Int @id @default(autoincrement()) + name String @unique + createdAt DateTime @default(now()) + + oauth2Clients AuthOauth2Client[] + groups IdentityGroup[] + profileAttributeNames IdentityProfileAttributeName[] + roles AuthRole[] +} + +model AuthOauth2Client { + id Int @id @default(autoincrement()) + + realmId Int + realm AuthRealm @relation(fields: [realmId], references: [id]) + + clientId String + clientSecret String? + + authorizationCodeFlowEnabled Boolean @default(false) + resourceOwnerPasswordCredentialsFlowEnabled Boolean @default(false) + clientCredentialsFlowEnabled Boolean @default(false) + idTokenEnabled Boolean @default(false) + refreshTokenEnabled Boolean @default(false) + + scopeMappings AuthOauth2ClientToAuthOauth2Scope[] + + @@unique([realmId, clientId]) +} + +model AuthOauth2Scope { + id Int @id @default(autoincrement()) + realmId Int + scope String + + profileAttributeMappings AuthOauth2ScopeToIdentityProfileAttributeName[] + clientMappings AuthOauth2ClientToAuthOauth2Scope[] + + @@unique([realmId, scope]) +} + +model AuthOauth2ClientToAuthOauth2Scope { + clientId Int + oauth2Client AuthOauth2Client @relation(fields: [clientId], references: [id]) + + scopeId Int + scope AuthOauth2Scope @relation(fields: [scopeId], references: [id]) + + @@id([clientId, scopeId]) +} + +model AuthOauth2ScopeToIdentityProfileAttributeName { + scopeId Int + scope AuthOauth2Scope @relation(fields: [scopeId], references: [id]) + + claimName String + + attributeId Int + attributes IdentityProfileAttributeName @relation(fields: [attributeId], references: [id]) + + @@id([scopeId, attributeId]) + @@unique([scopeId, claimName]) +} + +model AuthRole { + realmId Int + realm AuthRealm @relation(fields: [realmId], references: [id]) + + roleName String + + @@id([realmId, roleName]) +} + // // Namespace: Identity // +model EnumIdentityGroupRole { + enumValue String @id + + groups IdentityGroup[] +} + model IdentityGroup { - id Int @id @default(autoincrement()) - isAdmin Boolean @default(false) - name String? + id Int @id @default(autoincrement()) + + realmId Int + realm AuthRealm @relation(fields: [realmId], references: [id]) + + role String + roleRelation EnumIdentityGroupRole @relation(fields: [role], references: [enumValue]) + + name String? users IdentityGroupToIdentityUser[] davResources CloudDavResource[] @@ -35,8 +124,11 @@ model IdentityGroup { model IdentityGroupToIdentityUser { groupId Int group IdentityGroup @relation(fields: [groupId], references: [id]) - userId Int - user IdentityUser @relation(fields: [userId], references: [id]) + + userId Int + user IdentityUser @relation(fields: [userId], references: [id]) + + userIsGroupAdmin Boolean @default(false) @@id([groupId, userId]) } @@ -52,15 +144,29 @@ model IdentityUser { authDevices IdentityAuthDevice[] } +model IdentityProfileAttributeName { + id Int @id @default(autoincrement()) + + realmId Int + realm AuthRealm @relation(fields: [realmId], references: [id]) + + name String + + attributeUses IdentityProfileNonNormalized[] + scopeMappings AuthOauth2ScopeToIdentityProfileAttributeName[] +} + model IdentityProfileNonNormalized { userId Int user IdentityUser @relation(fields: [userId], references: [id]) - hashKey String + attributeNameId Int + attributeName IdentityProfileAttributeName @relation(fields: [attributeNameId], references: [id]) + hashValue String createdAt DateTime @default(now()) - @@id([userId, hashKey]) + @@id([userId, attributeNameId]) } model IdentityUserEmails { @@ -98,7 +204,7 @@ model IdentityAuthDevice { model IdentityAuthDeviceNonNormalized { authDeviceId Int - davResource IdentityAuthDevice @relation(fields: [authDeviceId], references: [id]) + authDevice IdentityAuthDevice @relation(fields: [authDeviceId], references: [id]) hashKey String hashValue String diff --git a/services/core/src/app.module.ts b/services/core/src/app.module.ts index 783ea0d..b7d880a 100644 --- a/services/core/src/app.module.ts +++ b/services/core/src/app.module.ts @@ -1,15 +1,20 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from './auth/auth.module'; + +import { AuthorizationServerModule } from './authorization-server/authorization-server.module'; +import { CacheModule } from './cache/cache.module'; import { ConfigModule } from './config/config.module'; -import { PrismaModule } from './prisma/prisma.module'; import { GraphqlModule } from './graphql/graphql.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthDomainModule } from './auth-domain/auth-domain.module'; @Module({ imports: [ - AuthModule, + AuthorizationServerModule, + CacheModule, ConfigModule, - PrismaModule, GraphqlModule, + PrismaModule, + AuthDomainModule, ], controllers: [], providers: [], diff --git a/services/core/src/auth/auth.module.ts b/services/core/src/auth/auth.module.ts deleted file mode 100644 index 7459c06..0000000 --- a/services/core/src/auth/auth.module.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({}) -export class AuthModule {} diff --git a/services/core/src/authorization-server/authorization-server.module.ts b/services/core/src/authorization-server/authorization-server.module.ts new file mode 100644 index 0000000..6180e25 --- /dev/null +++ b/services/core/src/authorization-server/authorization-server.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { OauthController } from './oauth/oauth.controller'; +import { IdentityModule } from '../identity-domain/identity.module'; + +@Module({ + imports: [ + IdentityModule, + ], + controllers: [OauthController] +}) +export class AuthorizationServerModule {} diff --git a/services/core/src/authorization-server/login/device-handlers/device-handler.interface.ts b/services/core/src/authorization-server/login/device-handlers/device-handler.interface.ts new file mode 100644 index 0000000..ca19a61 --- /dev/null +++ b/services/core/src/authorization-server/login/device-handlers/device-handler.interface.ts @@ -0,0 +1 @@ +export interface DeviceHandler {} diff --git a/services/core/src/authorization-server/login/device-handlers/password.handler.ts b/services/core/src/authorization-server/login/device-handlers/password.handler.ts new file mode 100644 index 0000000..287290d --- /dev/null +++ b/services/core/src/authorization-server/login/device-handlers/password.handler.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../../prisma/prisma.service'; +import { DeviceHandler } from './device-handler.interface'; + +@Injectable() +export class PasswordHandler implements DeviceHandler { + + constructor( + private readonly prismaService: PrismaService, + ) {} +} diff --git a/services/core/src/authorization-server/login/login.controller.ts b/services/core/src/authorization-server/login/login.controller.ts new file mode 100644 index 0000000..f9afddd --- /dev/null +++ b/services/core/src/authorization-server/login/login.controller.ts @@ -0,0 +1,53 @@ +import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; +import * as Joi from 'joi'; +import { LoginFirstContact, supportedDevices } from './login.interface'; +import { Identity } from '../../identity-domain/identity.interface'; +import { TokenManagementService } from '../token-management/token-management.service'; +import { IdentityAuthDeviceService } from '../../identity-domain/identity-auth-device.service'; + +@Controller({ + version: '1', + path: 'auth/:realm', +}) +export class LoginController { + + constructor( + private readonly identityAuthDeviceService: IdentityAuthDeviceService, + private readonly tokenManagementService: TokenManagementService, + ) {} + + @Post('login') + async login( + @Body() body: unknown, + ): Promise { + + const { value: request, error } = Joi.object({ + state: Joi.string().required(), + username: Joi.string().required(), + }).validate(body, { allowUnknown: false, abortEarly: false }); + + if (error) { + throw new BadRequestException(error.message); + } + + const authDevices = await this.identityAuthDeviceService.findByUsername(request.username); + + return { + state: request.state, + availableDevices: authDevices.filter(dev => supportedDevices.includes(dev.deviceType as Identity.AuthDevice.Type)), + continuation: await this.tokenManagementService.generateContinuationToken(), + } + } + + @Post('login/authenticate') + async loginAuthenticate( + + ) { + // parseLoginRequestBody + } + + @Post('logout') + async logout() { + + } +} diff --git a/services/core/src/authorization-server/login/login.interface.ts b/services/core/src/authorization-server/login/login.interface.ts new file mode 100644 index 0000000..b3236cc --- /dev/null +++ b/services/core/src/authorization-server/login/login.interface.ts @@ -0,0 +1,98 @@ +import { Auth, Identity } from '../../enumerations'; +import * as Joi from 'joi'; +import { declare } from '../../utils'; + +interface ILoginRequestMethod { + device: Identity.AuthDevice.Type; +} + +export namespace LoginFirstContact { + export interface Request { + state: string; + username: string; + } + export interface Response { + state: string; + continuation: string; + availableDevices: Identity.AuthDevice.Type[]; + } +} + +export namespace LoginPassword { + export interface Request extends ILoginRequestMethod { + device: Identity.AuthDevice.Type.Password; + password: string; + state: string; + continuation?: string; + } + + export type Response = Success | TwoFactorRequired | Failure | Timedout | DeviceLocked; + + type Success = { + status: Auth.Login.ResponseStatus.Success; + state: string; + } + + type TwoFactorRequired = { + status: Auth.Login.ResponseStatus.TwoFactorRequired; + state: string; + continuation: string; + availableDevices: Identity.AuthDevice.Type[]; + } + + type Failure = { + status: Auth.Login.ResponseStatus.Failure; + state: string; + continuation: string; + } + + type Timedout = { + status: Auth.Login.ResponseStatus.Timedout; + state: string; + } + + type DeviceLocked = { + status: Auth.Login.ResponseStatus.DeviceLocked; + state: string; + } +} + +type LoginMethodRequests = LoginPassword.Request; + +export const supportedDevices = [ + Identity.AuthDevice.Type.Password, +] + +const standardJoiValidationOptions: Joi.ValidationOptions = { + abortEarly: false, + allowUnknown: false, +} + +const loginPasswordValidator = Joi.object({ + device: Joi.string().required().allow(Identity.AuthDevice.Type.Password), + password: Joi.string().required(), + state: Joi.string().required(), + continuation: Joi.string(), +}).options(standardJoiValidationOptions); + +export const parseLoginRequestBody = (body: unknown): [null, LoginMethodRequests] | [Error, null] => { + + const { error: error1, value: simpleRequest } = Joi.object({ + device: Joi.string().required().allow(...supportedDevices), + }).validate(body); + + if (error1) { + return [error1, null]; + } + + const { error: error2, value: request } = declare() + .when(simpleRequest.device) + .matches(Identity.AuthDevice.Type.Password).then(() => loginPasswordValidator.validate(body)) + .resolveOrThrow(); + + if (error2) { + return [error2, null]; + } + + return [null, request]; +} diff --git a/services/core/src/authorization-server/login/login.service.ts b/services/core/src/authorization-server/login/login.service.ts new file mode 100644 index 0000000..6ad0802 --- /dev/null +++ b/services/core/src/authorization-server/login/login.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class LoginService { + +} diff --git a/services/core/src/authorization-server/oauth/oauth.controller.ts b/services/core/src/authorization-server/oauth/oauth.controller.ts new file mode 100644 index 0000000..cb265c7 --- /dev/null +++ b/services/core/src/authorization-server/oauth/oauth.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Post } from '@nestjs/common'; + +@Controller({ + version: '1', + path: 'auth/:realm/oauth2', +}) +export class OauthController { + + @Post('authorize') + async authorization() { + + } + + @Get('return') + async redirectReturn() { + + } + + @Post('token') + async token() { + + } + + @Get('user_info') + async userInfo() { + + } + + @Get('jwks_uri') + async jwksUri() { + + } + + @Get('introspection') + async introspection() { + + } + + @Get('revocation_endpoint') + async revokeBasic() { + + } + + @Post('revocation_endpoint') + async revoke() { + + } +} diff --git a/services/core/src/authorization-server/oauth/oauth.interface.ts b/services/core/src/authorization-server/oauth/oauth.interface.ts new file mode 100644 index 0000000..8e2f31d --- /dev/null +++ b/services/core/src/authorization-server/oauth/oauth.interface.ts @@ -0,0 +1,70 @@ +import { Auth } from '../../enumerations/auth.enumerations'; + +interface ErrorResponse { + state?: string; + error: Auth.Oauth2.Error; + error_description?: string; + error_uri?: string; +} + +namespace AuthorizationCode { + interface AuthorizationRequest { + response_type: Auth.Oauth2.ResponseType.Code; + client_id: string; + redirect_uri?: string; + scope?: string; + state?: string; + } + + interface AuthorizationResponse { + code: string; // 10min redis + state?: string; + } + + interface AccessTokenRequest { + grant_type: Auth.Oauth2.AuthorizationGrant.AuthorizationCode; + code: string; + redirect_uri?: string; + client_id: string; + } + + interface AccessTokenResponse { + access_token: string; + token_type: 'bearer'; + expires_in: number; + refresh_token?: string; + } +} + +// application/x-www-form-urlencoded +// Authorization header required if of type `confidential` +// Basic base64(clientId:clientSecret) +namespace ResourceOwner { + interface AccessTokenRequest { + grant_type: Auth.Oauth2.GrantType.Password; + username: string; + password: string; + scope?: string; + } + + interface AccessTokenResponse { + access_token: string; + token_type: 'bearer'; // ? + expires_in: number; + refresh_token?: string; + } +} + +// `confidential` only +namespace ClientCredentials { + interface AccessTokenRequest { + // grant_type: Auth.Oauth2.GrantType.ClientCredentials; + scope?: string; + } + + interface AccessTokenResponse { + access_token: string; + token_type: 'bearer'; + expires_in: number; + } +} diff --git a/services/core/src/authorization-server/token-management/token-management.service.ts b/services/core/src/authorization-server/token-management/token-management.service.ts new file mode 100644 index 0000000..54828c5 --- /dev/null +++ b/services/core/src/authorization-server/token-management/token-management.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; + +import { CacheService } from '../../cache/cache.symbols'; +import { ConfigService } from '../../config/config.service'; +import { Identity, SystemSettings } from '../../enumerations'; + +@Injectable() +export class TokenManagementService { + + private readonly signingSecret: string; + + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheService, + ) {} + + async generateContinuationToken(devicesUsed: Identity.AuthDevice.Type[]) { + + } + + private async signToken() { + const signingSecret = await this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret); + } +} diff --git a/services/core/src/authorization-server/token-management/token-mangement.interface.ts b/services/core/src/authorization-server/token-management/token-mangement.interface.ts new file mode 100644 index 0000000..f6c7674 --- /dev/null +++ b/services/core/src/authorization-server/token-management/token-mangement.interface.ts @@ -0,0 +1,9 @@ +import { Identity } from '../../enumerations'; + +export namespace TokenManagement { + interface GenerateContinuationTokenRequest { + deviceUsed: Identity.AuthDevice.Type; + identityUserUrn: string; + previousContinuationToken?: string; + } +} diff --git a/services/core/src/cache/cache.module.ts b/services/core/src/cache/cache.module.ts new file mode 100644 index 0000000..35aa3ce --- /dev/null +++ b/services/core/src/cache/cache.module.ts @@ -0,0 +1,19 @@ +import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; + +import { CacheService } from './cache.symbols'; + +@Module({ + imports: [ + NestCacheModule.register(), + ], + providers: [ + { + provide: CacheService, + useFactory: (cache) => cache, + inject: [CacheService], + } + ], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/services/core/src/cache/cache.symbols.ts b/services/core/src/cache/cache.symbols.ts new file mode 100644 index 0000000..b177b35 --- /dev/null +++ b/services/core/src/cache/cache.symbols.ts @@ -0,0 +1,5 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; + +export type CacheService = Cache; +export const CacheService = CACHE_MANAGER; diff --git a/services/core/src/config/config.struct.ts b/services/core/src/config/config.struct.ts index fb26792..d725517 100644 --- a/services/core/src/config/config.struct.ts +++ b/services/core/src/config/config.struct.ts @@ -3,13 +3,19 @@ import * as Joi from 'joi'; import { SystemSettings } from '../enumerations'; export interface Config { + [SystemSettings.Auth.Oauth2.Enabled]: boolean; + [SystemSettings.Auth.Oauth2.EncryptionSecret]: string; + [SystemSettings.Auth.TokenManagement.SigningSecret]: string; [SystemSettings.Graphql.Debug]: boolean; [SystemSettings.Graphql.IntrospectionEnabled]: boolean; [SystemSettings.Graphql.PlaygroundEnabled]: boolean; } export const configValidator: Joi.ObjectSchema = Joi.object({ - 'graphql.debug.enabled': Joi.boolean().required(), - 'graphql.introspection.enabled': Joi.boolean().required(), - 'graphql.playground.enabled': Joi.boolean().required(), + [SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(), + [SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(), + [SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(), + [SystemSettings.Graphql.Debug]: Joi.boolean().required(), + [SystemSettings.Graphql.IntrospectionEnabled]: Joi.boolean().required(), + [SystemSettings.Graphql.PlaygroundEnabled]: Joi.boolean().required(), }); diff --git a/services/core/src/domain/auth-domain/auth-domain.service.ts b/services/core/src/domain/auth-domain/auth-domain.service.ts new file mode 100644 index 0000000..d7eb5f0 --- /dev/null +++ b/services/core/src/domain/auth-domain/auth-domain.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AuthDomainService {} diff --git a/services/core/src/prisma/data-migrations/00-application-bootstrap-data-migration.ts b/services/core/src/domain/data-migrations/00-application-bootstrap-data-migration.ts similarity index 100% rename from services/core/src/prisma/data-migrations/00-application-bootstrap-data-migration.ts rename to services/core/src/domain/data-migrations/00-application-bootstrap-data-migration.ts diff --git a/services/core/src/prisma/data-migrations/data-migration.interface.ts b/services/core/src/domain/data-migrations/data-migration.interface.ts similarity index 100% rename from services/core/src/prisma/data-migrations/data-migration.interface.ts rename to services/core/src/domain/data-migrations/data-migration.interface.ts diff --git a/services/core/src/prisma/data-migrations/index.ts b/services/core/src/domain/data-migrations/index.ts similarity index 100% rename from services/core/src/prisma/data-migrations/index.ts rename to services/core/src/domain/data-migrations/index.ts diff --git a/services/core/src/prisma/prisma.module.ts b/services/core/src/domain/domain.module.ts similarity index 71% rename from services/core/src/prisma/prisma.module.ts rename to services/core/src/domain/domain.module.ts index a1c599e..0f0c06a 100644 --- a/services/core/src/prisma/prisma.module.ts +++ b/services/core/src/domain/domain.module.ts @@ -6,6 +6,8 @@ import { PrismaService } from './prisma.service'; providers: [ PrismaService, ], - exports: [PrismaService], + exports: [ + + ], }) -export class PrismaModule {} +export class DomainModule {} diff --git a/services/core/src/domain/identity-domain/identity-auth-device.service.ts b/services/core/src/domain/identity-domain/identity-auth-device.service.ts new file mode 100644 index 0000000..232f6ad --- /dev/null +++ b/services/core/src/domain/identity-domain/identity-auth-device.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; +import { Identity } from './identity.interface'; + + +@Injectable() +export class IdentityAuthDeviceService { + + constructor( + private readonly prismaService: PrismaService, + ) {} + + async findByUsername(username: string): Promise { + + const devices = await this.prismaService.identityAuthDevice.findMany({ + include: { + hashMapPairs: true, + }, + where: { + user: { + username, + } + } + }); + + return devices.map(d => Identity.AuthDevice.modelToEntity(d, d.hashMapPairs)); + } +} diff --git a/services/core/src/domain/identity-domain/identity-user.service.ts b/services/core/src/domain/identity-domain/identity-user.service.ts new file mode 100644 index 0000000..db24835 --- /dev/null +++ b/services/core/src/domain/identity-domain/identity-user.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class IdentityService { + +} diff --git a/services/core/src/domain/identity-domain/identity.interface.ts b/services/core/src/domain/identity-domain/identity.interface.ts new file mode 100644 index 0000000..2ea4ecf --- /dev/null +++ b/services/core/src/domain/identity-domain/identity.interface.ts @@ -0,0 +1,63 @@ +import { IdentityAuthDevice, IdentityAuthDeviceNonNormalized } from '@prisma/client'; +import { normalizePairs } from '../../utils'; + +export namespace Identity { + + export interface AuthDevice { + urn: string; + userId: number; + deviceType: AuthDevice.Type; + createdAt: Date; + } + + export namespace AuthDevice { + export enum Type { + Password = 'password', + ApplicationPassword = 'application_password', + } + + export enum PasswordHashKey { + Expiry = 'expiry', + PasswordHashString = 'password_hash_string', + Locked = 'locked', + } + + export interface Password extends AuthDevice { + expiry: Date; + passwordHashString: string; + locked: boolean; + } + + export function modelToEntity(model: IdentityAuthDevice, hashMapPairs: IdentityAuthDeviceNonNormalized[]): AuthDevice { + + const map = normalizePairs(hashMapPairs); + + const entity: AuthDevice = { + urn: `urn:identity:auth-device:${model.id}`, + userId: model.userId, + deviceType: model.deviceType as Type, + createdAt: new Date(model.createdAt), + } + + if (model.deviceType === AuthDevice.Type.Password) { + const passwordEntity: Password = { + ...entity, + expiry: new Date(map[PasswordHashKey.Expiry] as string), + passwordHashString: map[PasswordHashKey.PasswordHashString] as string, + locked: map[PasswordHashKey.Locked] as boolean, + } + return passwordEntity; + } + + return entity; + } + } + + export namespace Group { + export enum Role { + SystemAdmin = 'SYSTEM_ADMIN', + RealmAdmin = 'REALM_ADMIN', + Standard = 'STANDARD', + } + } +} diff --git a/services/core/src/domain/identity-domain/identity.module.ts b/services/core/src/domain/identity-domain/identity.module.ts new file mode 100644 index 0000000..d5b0052 --- /dev/null +++ b/services/core/src/domain/identity-domain/identity.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { PrismaModule } from '../prisma/prisma.module'; +import { IdentityAuthDeviceService } from './identity-auth-device.service'; + +@Module({ + imports: [PrismaModule], + providers: [ + IdentityAuthDeviceService, + ], + exports: [ + IdentityAuthDeviceService, + ], +}) +export class IdentityModule {} diff --git a/services/core/src/domain/prisma.service.ts b/services/core/src/domain/prisma.service.ts new file mode 100644 index 0000000..67e65e1 --- /dev/null +++ b/services/core/src/domain/prisma.service.ts @@ -0,0 +1,40 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +import { _00ApplicationBootstrapDataMigration } from './data-migrations'; +import { Identity } from './identity-domain/identity.interface'; +import { CloudDav } from '../enumerations'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + await this.ensureEnumIntegrity(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } + + async ensureEnumIntegrity() { + + const prisma = this; + + const enumsRegistered: [string, string[]][] = [ + ['enumIdentityGroupRole', Object.values(Identity.Group.Role)], + ['enumIdentityAuthDeviceType', Object.values(Identity.AuthDevice.Type)], + ['enumCloudDavResourceType', Object.values(CloudDav.Resource.Type)], + ]; + + for (const [dbRunner, known] of enumsRegistered) { + await prisma.$transaction(async (tx) => { + const result = await tx[dbRunner].findMany(); + const existing = result.map(e => e.enumValue); + const missing = known.filter(k => !existing.includes(k)); + await tx[dbRunner].createMany({ + data: missing.map(enumValue => ({ enumValue })), + }); + }); + } + } +} diff --git a/services/core/src/enumerations/auth.enumerations.ts b/services/core/src/enumerations/auth.enumerations.ts new file mode 100644 index 0000000..5344a8b --- /dev/null +++ b/services/core/src/enumerations/auth.enumerations.ts @@ -0,0 +1,44 @@ +export namespace Auth { + + export namespace Login { + export enum ResponseStatus { + Success = 'success', + TwoFactorRequired = '2fa', + Failure = 'failure', + Timedout = 'timedout', + DeviceLocked = 'locked', + } + } + + export namespace Oauth2 { + export enum TokenClaims { + Issuer = 'iss', + Expiry = 'exp', + TokenId = 'jwi', + IssuedAt = 'iat', + } + export enum AuthorizationGrant { + AuthorizationCode = 'authorization_code', + Implicit = 'implicit', + ResourceOwnerPasswordCredentials = 'resource_owner_password_credentials', + ClientCredentials = 'client_credentials', + } + export enum GrantType { + RefreshToken = 'refresh_token', + Password = 'password', + } + export enum ResponseType { + Code = 'code', + Token = 'token', + } + export enum Error { + InvalidRequest = 'invalid_request', + UnauthorizedClient = 'unauthorized_client', + AccessDenied = 'access_denied', + UnsupportedResponseType = 'unsupported_response_type', + InvalidScope = 'invalid_scope', + ServerError = 'server_error', + TemporarilyUnavailable = 'temporarily_unavailable', + } + } +} \ No newline at end of file diff --git a/services/core/src/enumerations/identity.enumerations.ts b/services/core/src/enumerations/identity.enumerations.ts deleted file mode 100644 index c654e15..0000000 --- a/services/core/src/enumerations/identity.enumerations.ts +++ /dev/null @@ -1,13 +0,0 @@ -export namespace Identity { - - export namespace AuthDevice { - export enum Type { - Password = 'password', - } - - export enum PasswordHashKey { - Expiry = 'expiry', - PasswordHashString = 'password_hash_string', - } - } -} diff --git a/services/core/src/enumerations/index.ts b/services/core/src/enumerations/index.ts index 7d61db1..2c3e6b8 100644 --- a/services/core/src/enumerations/index.ts +++ b/services/core/src/enumerations/index.ts @@ -1,3 +1,3 @@ +export * from './auth.enumerations'; export * from './cloud-dav.enumerations'; -export * from './identity.enumerations'; export * from './system-settings.enumerations'; diff --git a/services/core/src/enumerations/system-settings.enumerations.ts b/services/core/src/enumerations/system-settings.enumerations.ts index c72e469..570341f 100644 --- a/services/core/src/enumerations/system-settings.enumerations.ts +++ b/services/core/src/enumerations/system-settings.enumerations.ts @@ -1,7 +1,23 @@ export namespace SystemSettings { + export namespace Auth { + export enum Oauth2 { + Enabled = 'auth.oauth2.enabled', + EncryptionSecret = 'auth.oauth2.encryption_secret', + } + export enum TokenManagement { + SigningSecret = 'auth.token-management.signing_secret', + } + } + export enum Graphql { Debug = 'graphql.debug.enabled', IntrospectionEnabled = 'graphql.introspection.enabled', PlaygroundEnabled = 'graphql.playground.enabled', } + + export namespace Dav { + export enum Contacts { + Enabled = 'dav.contacts.enabled', + } + } } diff --git a/services/core/src/prisma/prisma.service.ts b/services/core/src/prisma/prisma.service.ts deleted file mode 100644 index f66818d..0000000 --- a/services/core/src/prisma/prisma.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; - -import { _00ApplicationBootstrapDataMigration } from './data-migrations'; - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - async onModuleInit() { - await this.$connect(); - } - - async onModuleDestroy() { - await this.$disconnect(); - } -} diff --git a/services/core/src/utils/index.ts b/services/core/src/utils/index.ts index 05bec30..b1e0c2b 100644 --- a/services/core/src/utils/index.ts +++ b/services/core/src/utils/index.ts @@ -1,2 +1,4 @@ export * from './normalize-hash-set'; -export * from './secure-string'; \ No newline at end of file +export * from './secure-string'; +export * from './snake-to-camel'; +export * from './when-clause'; diff --git a/services/core/src/utils/normalize-hash-set.ts b/services/core/src/utils/normalize-pairs.ts similarity index 100% rename from services/core/src/utils/normalize-hash-set.ts rename to services/core/src/utils/normalize-pairs.ts diff --git a/services/core/src/utils/snake-to-camel.ts b/services/core/src/utils/snake-to-camel.ts new file mode 100644 index 0000000..54b9faf --- /dev/null +++ b/services/core/src/utils/snake-to-camel.ts @@ -0,0 +1,8 @@ +// https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase +export const snakeToCamel = str => + str.toLowerCase().replace(/([-_][a-z])/g, group => + group + .toUpperCase() + .replace('-', '') + .replace('_', '') + ); \ No newline at end of file diff --git a/services/core/src/utils/when-clause.ts b/services/core/src/utils/when-clause.ts new file mode 100644 index 0000000..245af7f --- /dev/null +++ b/services/core/src/utils/when-clause.ts @@ -0,0 +1,45 @@ +type Literal = string | number; +type Resolved = T | (() => T); +type Then = (result: Resolved) => WhenClosure; + +const defaultErrorMessage = 'Runtime exception: failed to handle all scenarios of when clause'; + +class WhenClosure { + + private readonly stack: {[key: Literal]: Resolved} = {}; + + constructor(private readonly expression: Literal) {} + + matches(literal: K): { then: Then } { + return { + then: (result: Resolved) => { + this.stack[literal] = result; + return this; + } + } + } + + else(result: Resolved): T { + const resolved = this.expression in this.stack ? this.stack[this.expression] : result; + if (resolved instanceof Function) { + return resolved(); + } + + return resolved; + } + + resolveOrThrow(errorMessage = defaultErrorMessage): T { + if (this.expression in this.stack) { + const resolved = this.stack[this.expression]; + if (resolved instanceof Function) { + return resolved(); + } + return resolved; + } + throw new Error(errorMessage); + } +} + +export const declare = () => ({ + when: (expression: K) => new WhenClosure(expression) +}); diff --git a/services/core/test/app.e2e-spec.ts b/services/core/test/app.e2e-spec.ts deleted file mode 100644 index 50cda62..0000000 --- a/services/core/test/app.e2e-spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); diff --git a/services/core/test/jest-e2e.json b/services/core/test/jest-e2e.json deleted file mode 100644 index e9d912f..0000000 --- a/services/core/test/jest-e2e.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -}