modify provisioning

This commit is contained in:
2026-07-13 16:49:13 +09:00
parent acc0e471a0
commit 05348db155
5 changed files with 297 additions and 2 deletions
@@ -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");
+21
View File
@@ -227,6 +227,27 @@ model ProvisionedDevice {
@@map("provisioned_devices") @@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 { model LauncherSshKey {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
accountEmail String @map("account_email") accountEmail String @map("account_email")
@@ -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;
}
+36 -1
View File
@@ -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 { ConfigService } from '@nestjs/config';
import { CreateProvisioningDto } from './dto/create-provisioning.dto'; import { CreateProvisioningDto } from './dto/create-provisioning.dto';
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
import {
ConsumeDeviceAuthorizationDto,
} from './dto/device-authorization.dto';
import { ProvisioningService } from './provisioning.service'; import { ProvisioningService } from './provisioning.service';
import type { ProvisioningResponse } 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') @Post('ssh-keys/lookup')
listSshKeys( listSshKeys(
@Headers('x-lms-gateway-token') token: string | undefined, @Headers('x-lms-gateway-token') token: string | undefined,
+153 -1
View File
@@ -1,16 +1,20 @@
import { import {
BadGatewayException, BadGatewayException,
ConflictException,
ForbiddenException, ForbiddenException,
GoneException,
Injectable, Injectable,
NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash } from 'crypto'; import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateProvisioningDto } from './dto/create-provisioning.dto'; import { CreateProvisioningDto } from './dto/create-provisioning.dto';
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto'; import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
import { ConsumeDeviceAuthorizationDto } from './dto/device-authorization.dto';
export type ProvisioningResponse = { export type ProvisioningResponse = {
installTailscale: boolean; installTailscale: boolean;
@@ -75,6 +79,141 @@ export class ProvisioningService {
return this.createProvisioningForUser(provisioningDto, authUser); return this.createProvisioningForUser(provisioningDto, authUser);
} }
async createDeviceAuthorization(accessToken: string | undefined) {
const authUser = await this.authenticateAccessToken(accessToken);
const expiresInSeconds = Math.max(
60,
this.configService.get<number>('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<AuthLoginResponse['user']> = {
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( private async createProvisioningForUser(
provisioningDto: ProvisioningInput, provisioningDto: ProvisioningInput,
authUser: NonNullable<AuthLoginResponse['user']>, authUser: NonNullable<AuthLoginResponse['user']>,
@@ -273,6 +412,19 @@ export class ProvisioningService {
return normalized || null; 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) { private normalizePublicSshKey(value?: string | null) {
const normalized = value?.trim().replace(/\s+/g, ' '); const normalized = value?.trim().replace(/\s+/g, ' ');
if (!normalized) return null; if (!normalized) return null;