From 05348db155e061fcea0dd2493f3d11b02a5809e9 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 13 Jul 2026 16:49:13 +0900 Subject: [PATCH] modify provisioning --- .../migration.sql | 23 +++ prisma/schema.prisma | 21 +++ .../dto/device-authorization.dto.ts | 64 ++++++++ src/provisioning/provisioning.controller.ts | 37 ++++- src/provisioning/provisioning.service.ts | 154 +++++++++++++++++- 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260713093000_add_device_authorizations/migration.sql create mode 100644 src/provisioning/dto/device-authorization.dto.ts diff --git a/prisma/migrations/20260713093000_add_device_authorizations/migration.sql b/prisma/migrations/20260713093000_add_device_authorizations/migration.sql new file mode 100644 index 0000000..625d7ed --- /dev/null +++ b/prisma/migrations/20260713093000_add_device_authorizations/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "device_authorizations" ( + "id" UUID NOT NULL, + "device_code_hash" TEXT NOT NULL, + "user_code" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "auth_user_id" UUID, + "account_email" TEXT, + "auth_user_name" TEXT, + "auth_user_role" TEXT, + "auth_user_roles" JSONB, + "device" JSONB, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "approved_at" TIMESTAMPTZ(3), + "consumed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "device_authorizations_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "device_authorizations_device_code_hash_key" ON "device_authorizations"("device_code_hash"); +CREATE UNIQUE INDEX "device_authorizations_user_code_key" ON "device_authorizations"("user_code"); +CREATE INDEX "device_authorizations_status_expires_at_idx" ON "device_authorizations"("status", "expires_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56a8a11..280f52e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -227,6 +227,27 @@ model ProvisionedDevice { @@map("provisioned_devices") } +model DeviceAuthorization { + id String @id @default(uuid()) @db.Uuid + deviceCodeHash String @unique @map("device_code_hash") + userCode String @unique @map("user_code") + status String @default("PENDING") + authUserId String? @map("auth_user_id") @db.Uuid + accountEmail String? @map("account_email") + authUserName String? @map("auth_user_name") + authUserRole String? @map("auth_user_role") + authUserRoles Json? @map("auth_user_roles") + device Json? + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + approvedAt DateTime? @map("approved_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([status, expiresAt]) + @@map("device_authorizations") +} + model LauncherSshKey { id String @id @default(uuid()) @db.Uuid accountEmail String @map("account_email") diff --git a/src/provisioning/dto/device-authorization.dto.ts b/src/provisioning/dto/device-authorization.dto.ts new file mode 100644 index 0000000..5f7549c --- /dev/null +++ b/src/provisioning/dto/device-authorization.dto.ts @@ -0,0 +1,64 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +import { ProvisioningDeviceDto } from './create-provisioning.dto'; + +export class CreateDeviceAuthorizationDto { + @IsString() + @IsOptional() + hostname?: string; + + @IsString() + @IsOptional() + os?: string; + + @IsString() + @IsOptional() + arch?: string; + + @IsString() + @IsOptional() + kernel?: string; + + @IsString() + @IsOptional() + distro?: string; + + @IsString() + @IsOptional() + usageType?: string; + + @IsString() + @IsOptional() + sshUser?: string; + + @IsString() + @IsOptional() + workspaceRoot?: string; + + @IsString() + @IsOptional() + publicSshKey?: string; + + @IsObject() + @ValidateNested() + @Type(() => ProvisioningDeviceDto) + @IsOptional() + device?: ProvisioningDeviceDto; +} + +export class ApproveDeviceAuthorizationDto { + @IsString() + @IsNotEmpty() + userCode!: string; +} + +export class ConsumeDeviceAuthorizationDto extends CreateDeviceAuthorizationDto { + @IsString() + @IsNotEmpty() + account!: string; + + @IsString() + @IsNotEmpty() + userCode!: string; +} diff --git a/src/provisioning/provisioning.controller.ts b/src/provisioning/provisioning.controller.ts index d54e154..aa701e5 100644 --- a/src/provisioning/provisioning.controller.ts +++ b/src/provisioning/provisioning.controller.ts @@ -1,8 +1,11 @@ -import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CreateProvisioningDto } from './dto/create-provisioning.dto'; import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; +import { + ConsumeDeviceAuthorizationDto, +} from './dto/device-authorization.dto'; import { ProvisioningService } from './provisioning.service'; import type { ProvisioningResponse } from './provisioning.service'; @@ -31,6 +34,38 @@ export class ProvisioningController { ); } + @Post('device-authorizations') + createDeviceAuthorization( + @Headers('authorization') authorization: string | undefined, + ) { + return this.provisioningService.createDeviceAuthorization( + this.getBearerToken(authorization), + ); + } + + @Get('device-authorizations/current') + currentDeviceAuthorization( + @Headers('authorization') authorization: string | undefined, + ) { + return this.provisioningService.getCurrentDeviceAuthorization( + this.getBearerToken(authorization), + ); + } + + @Post('device-authorizations/current/cancel') + cancelCurrentDeviceAuthorization( + @Headers('authorization') authorization: string | undefined, + ) { + return this.provisioningService.cancelCurrentDeviceAuthorization( + this.getBearerToken(authorization), + ); + } + + @Post('device-authorizations/consume') + consumeDeviceAuthorization(@Body() body: ConsumeDeviceAuthorizationDto) { + return this.provisioningService.consumeDeviceAuthorization(body); + } + @Post('ssh-keys/lookup') listSshKeys( @Headers('x-lms-gateway-token') token: string | undefined, diff --git a/src/provisioning/provisioning.service.ts b/src/provisioning/provisioning.service.ts index d50e478..8af75d3 100644 --- a/src/provisioning/provisioning.service.ts +++ b/src/provisioning/provisioning.service.ts @@ -1,16 +1,20 @@ import { BadGatewayException, + ConflictException, ForbiddenException, + GoneException, Injectable, + NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { CreateProvisioningDto } from './dto/create-provisioning.dto'; import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; +import { ConsumeDeviceAuthorizationDto } from './dto/device-authorization.dto'; export type ProvisioningResponse = { installTailscale: boolean; @@ -75,6 +79,141 @@ export class ProvisioningService { return this.createProvisioningForUser(provisioningDto, authUser); } + async createDeviceAuthorization(accessToken: string | undefined) { + const authUser = await this.authenticateAccessToken(accessToken); + const expiresInSeconds = Math.max( + 60, + this.configService.get('DEVICE_AUTHORIZATION_TTL_SECONDS', 300), + ); + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const userCode = this.createUserCode(); + try { + await this.prisma.deviceAuthorization.create({ + data: { + deviceCodeHash: this.hashDeviceCode(randomBytes(32).toString('base64url')), + userCode, + status: 'APPROVED', + authUserId: authUser.id, + accountEmail: authUser.email, + authUserName: authUser.name ?? null, + authUserRole: authUser.role ?? null, + authUserRoles: authUser.roles ? this.cleanJson(authUser.roles) : undefined, + expiresAt, + approvedAt: new Date(), + }, + }); + return { + code: userCode, + expiresAt, + expiresIn: expiresInSeconds, + }; + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') { + throw error; + } + } + } + + throw new ConflictException('Unable to issue a device authorization code'); + } + + async getCurrentDeviceAuthorization(accessToken: string | undefined) { + const authUser = await this.authenticateAccessToken(accessToken); + const now = new Date(); + const authorization = await this.prisma.deviceAuthorization.findFirst({ + where: { + authUserId: authUser.id, + accountEmail: authUser.email, + status: 'APPROVED', + device: { equals: Prisma.DbNull }, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: 'desc' }, + select: { userCode: true, expiresAt: true }, + }); + + return authorization + ? { code: authorization.userCode, expiresAt: authorization.expiresAt } + : null; + } + + async cancelCurrentDeviceAuthorization(accessToken: string | undefined) { + const authUser = await this.authenticateAccessToken(accessToken); + const result = await this.prisma.deviceAuthorization.updateMany({ + where: { + authUserId: authUser.id, + accountEmail: authUser.email, + status: 'APPROVED', + device: { equals: Prisma.DbNull }, + expiresAt: { gt: new Date() }, + }, + data: { status: 'CANCELLED' }, + }); + + return { cancelled: result.count > 0 }; + } + + async consumeDeviceAuthorization(body: ConsumeDeviceAuthorizationDto) { + const userCode = this.normalizeUserCode(body.userCode); + const authorization = await this.prisma.deviceAuthorization.findUnique({ + where: { userCode }, + }); + + if (!authorization) { + throw new NotFoundException('Device authorization request was not found'); + } + if (authorization.expiresAt <= new Date()) { + if (authorization.status === 'PENDING' || authorization.status === 'APPROVED') { + await this.prisma.deviceAuthorization.update({ + where: { id: authorization.id }, + data: { status: 'EXPIRED' }, + }); + } + throw new GoneException('Device authorization request has expired'); + } + if (authorization.status !== 'APPROVED' || !authorization.authUserId || !authorization.accountEmail) { + throw new ConflictException('Device authorization request is no longer available'); + } + if (authorization.accountEmail.toLowerCase() !== body.account.trim().toLowerCase()) { + throw new UnauthorizedException('Account does not match the registration code'); + } + + const claimed = await this.prisma.deviceAuthorization.updateMany({ + where: { id: authorization.id, status: 'APPROVED' }, + data: { status: 'CONSUMING' }, + }); + if (claimed.count !== 1) { + return { status: 'PENDING' as const }; + } + + const authUser: NonNullable = { + id: authorization.authUserId, + email: authorization.accountEmail, + name: authorization.authUserName, + role: authorization.authUserRole ?? undefined, + roles: Array.isArray(authorization.authUserRoles) + ? authorization.authUserRoles.filter((role): role is string => typeof role === 'string') + : undefined, + }; + + try { + const provisioning = await this.createProvisioningForUser(body, authUser); + await this.prisma.deviceAuthorization.update({ + where: { id: authorization.id }, + data: { status: 'CONSUMED', consumedAt: new Date() }, + }); + return provisioning; + } catch (error) { + await this.prisma.deviceAuthorization.update({ + where: { id: authorization.id }, + data: { status: 'APPROVED' }, + }); + throw error; + } + } + private async createProvisioningForUser( provisioningDto: ProvisioningInput, authUser: NonNullable, @@ -273,6 +412,19 @@ export class ProvisioningService { return normalized || null; } + private hashDeviceCode(value: string) { + return createHash('sha256').update(value).digest('hex'); + } + + private createUserCode() { + const value = randomBytes(4).readUInt32BE() % 1_000_000; + return String(value).padStart(6, '0'); + } + + private normalizeUserCode(value: string) { + return value.toUpperCase().replace(/[^A-Z0-9]/g, ''); + } + private normalizePublicSshKey(value?: string | null) { const normalized = value?.trim().replace(/\s+/g, ' '); if (!normalized) return null;