This commit is contained in:
Matthew Bessette 2024-04-15 01:45:41 +00:00
parent b790a70d3a
commit 2410ef056c
46 changed files with 886 additions and 93 deletions

View File

@ -0,0 +1,9 @@
type AuthOauth2ClientToAuthRealmEdge {
data: AuthRealm
error: AuthOauth2ClientToAuthRealmError
}
type AuthOauth2ClientToAuthOauth2ScopesEdge {
data: [AuthOauth2Scope]
error: AuthOauth2ClientToAuthOauth2ScopesError
}

View File

@ -0,0 +1,4 @@
enum AuthOauth2ClientTypeEnum {
CONFIDENTIAL
PUBLIC
}

View File

@ -0,0 +1,7 @@
enum AuthOauth2ClientToAuthRealmError {
UNKNOWN
}
enum AuthOauth2ClientToAuthOauth2ScopesError {
UNKNOWN
}

View File

@ -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
}

View File

@ -1,3 +1,10 @@
enum IdentityAuthDeviceTypeEnum {
PASSWORD
APPLICATION_PASSWORD
}
enum IdentityGroupRoleEnum {
SYSTEM_ADMIN
REALM_ADMIN
STANDARD
}

View File

@ -0,0 +1,10 @@
type IdentityUserOutput {
error:
data: IdentityUser
}
type Query {
identityUsers()
identityUser(urn: String!): IdentityUserOutput!
myUser
}

View File

@ -1,6 +1,7 @@
type IdentityGroup {
urn: ID!
isAdmin: Boolean!
role: IdentityGroupRoleEnum!
name: String
Users: IdentityGroupToIdentityUserEdge!

View File

@ -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",

View File

@ -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"
},

View File

@ -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

View File

@ -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: [],

View File

@ -1,4 +0,0 @@
import { Module } from '@nestjs/common';
@Module({})
export class AuthModule {}

View File

@ -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 {}

View File

@ -0,0 +1 @@
export interface DeviceHandler {}

View File

@ -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,
) {}
}

View File

@ -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<LoginFirstContact.Response> {
const { value: request, error } = Joi.object<LoginFirstContact.Request, true>({
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() {
}
}

View File

@ -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<LoginPassword.Request, true>({
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<ILoginRequestMethod, true>({
device: Joi.string().required().allow(...supportedDevices),
}).validate(body);
if (error1) {
return [error1, null];
}
const { error: error2, value: request } = declare<Joi.ValidationResult, Identity.AuthDevice.Type>()
.when(simpleRequest.device)
.matches(Identity.AuthDevice.Type.Password).then(() => loginPasswordValidator.validate(body))
.resolveOrThrow();
if (error2) {
return [error2, null];
}
return [null, request];
}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoginService {
}

View File

@ -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() {
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
import { Identity } from '../../enumerations';
export namespace TokenManagement {
interface GenerateContinuationTokenRequest {
deviceUsed: Identity.AuthDevice.Type;
identityUserUrn: string;
previousContinuationToken?: string;
}
}

19
services/core/src/cache/cache.module.ts vendored Normal file
View File

@ -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 {}

View File

@ -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;

View File

@ -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<Config> = Joi.object<Config, true>({
'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(),
});

View File

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AuthDomainService {}

View File

@ -6,6 +6,8 @@ import { PrismaService } from './prisma.service';
providers: [
PrismaService,
],
exports: [PrismaService],
exports: [
],
})
export class PrismaModule {}
export class DomainModule {}

View File

@ -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<Identity.AuthDevice[]> {
const devices = await this.prismaService.identityAuthDevice.findMany({
include: {
hashMapPairs: true,
},
where: {
user: {
username,
}
}
});
return devices.map(d => Identity.AuthDevice.modelToEntity(d, d.hashMapPairs));
}
}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class IdentityService {
}

View File

@ -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',
}
}
}

View File

@ -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 {}

View File

@ -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<string>(Identity.Group.Role)],
['enumIdentityAuthDeviceType', Object.values<string>(Identity.AuthDevice.Type)],
['enumCloudDavResourceType', Object.values<string>(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 })),
});
});
}
}
}

View File

@ -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',
}
}
}

View File

@ -1,13 +0,0 @@
export namespace Identity {
export namespace AuthDevice {
export enum Type {
Password = 'password',
}
export enum PasswordHashKey {
Expiry = 'expiry',
PasswordHashString = 'password_hash_string',
}
}
}

View File

@ -1,3 +1,3 @@
export * from './auth.enumerations';
export * from './cloud-dav.enumerations';
export * from './identity.enumerations';
export * from './system-settings.enumerations';

View File

@ -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',
}
}
}

View File

@ -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();
}
}

View File

@ -1,2 +1,4 @@
export * from './normalize-hash-set';
export * from './secure-string';
export * from './secure-string';
export * from './snake-to-camel';
export * from './when-clause';

View File

@ -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('_', '')
);

View File

@ -0,0 +1,45 @@
type Literal = string | number;
type Resolved<T> = T | (() => T);
type Then<T, K extends Literal> = (result: Resolved<T>) => WhenClosure<T, K>;
const defaultErrorMessage = 'Runtime exception: failed to handle all scenarios of when clause';
class WhenClosure<T, K extends Literal> {
private readonly stack: {[key: Literal]: Resolved<T>} = {};
constructor(private readonly expression: Literal) {}
matches(literal: K): { then: Then<T, K> } {
return {
then: (result: Resolved<T>) => {
this.stack[literal] = result;
return this;
}
}
}
else(result: Resolved<T>): 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 = <T, K extends Literal = Literal>() => ({
when: (expression: K) => new WhenClosure<T, K>(expression)
});

View File

@ -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!');
});
});

View File

@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}