feat: standalone app login & work around Zitadel race condition (#556)

This commit is contained in:
Paul Makles
2026-09-03 13:35:35 +01:00
committed by GitHub
parent f31f9be024
commit 312062e33c
42 changed files with 1178 additions and 205 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#MISE description="Run standalone-app in development mode (vite UI + tsx server)"
#MISE depends=["yucca-sdk:orchestration-ui:generate-fetch-client", "yucca-sdk:orchestration-api:build"]
#MISE depends=["yucca-sdk:orchestration-ui:generate-fetch-client", "yucca-sdk:orchestration-api:build", "yucca-sdk:orchestration-ui:build"]
set -e
export STANDALONE_WEB_PORT=${STANDALONE_WEB_PORT:-36067}
export PORT=${PORT:-22676}
+2 -2
View File
@@ -9,8 +9,8 @@
> [!NOTE]
> The user interface for the standalone app is unpolished and may have some rough edges.
> [!WARNING]
> The app has no authentication, take care when exposing any ports.
> [!NOTE]
> This app does not require authentication until FUTO Backups account is connected. After which, you can only use the environment flag `YUCCA_DISABLE_AUTH=true` to disable it.
Run using Docker:
+30 -5
View File
@@ -10,10 +10,23 @@ import { waitForLog } from 'src/victoria-logs';
const baseUrl = `http://localhost:22676`;
let socket: Socket;
const startDeviceFlow = async () => {
const events = createEventSource(`${baseUrl}/api/yucca/auth/oidc/device`);
for await (const { data } of events) {
const message = JSON.parse(data);
if (message.type === 'START') {
return { events, userCode: message.userCode as string, verificationUri: message.verificationUri as string };
}
}
throw new Error('Device flow ended before it started');
};
const login = async () => {
const backendCreated = waitForMessage('BackendCreate');
const { userCode, verificationUri } = await sdk.oidcDeviceFlow();
const { events, userCode, verificationUri } = await startDeviceFlow();
const approveUrl = new URL('/api/form/device', verificationUri);
approveUrl.searchParams.set('user_code', userCode);
@@ -25,6 +38,7 @@ const login = async () => {
}
await backendCreated;
events.close();
};
beforeAll(async () => {
@@ -61,6 +75,8 @@ describe('Onboarding (before setup)', () => {
await expect(sdk.onboardingStatus()).resolves.toEqual({
status: 'ready',
hasTelemetry: 'none',
requiresAuthentication: false,
isAuthenticated: false,
hasBackend: false,
hasOnboardedKey: false,
hasBackup: false,
@@ -72,10 +88,11 @@ describe('Onboarding (before setup)', () => {
describe('Auth', () => {
it('provides an OIDC device flow code', async () => {
await expect(sdk.oidcDeviceFlow()).resolves.toEqual({
userCode: expect.any(String),
verificationUri: expect.any(String),
});
const { events, userCode, verificationUri } = await startDeviceFlow();
events.close();
expect(userCode).toEqual(expect.any(String));
expect(verificationUri).toEqual(expect.any(String));
});
it('should log us in using IdP', async () => {
@@ -168,6 +185,8 @@ describe('Onboarding', () => {
await expect(sdk.onboardingStatus()).resolves.toEqual({
status: 'ready',
hasTelemetry: 'none',
requiresAuthentication: false,
isAuthenticated: false,
hasBackend: true,
hasOnboardedKey: true,
hasBackup: false,
@@ -182,6 +201,8 @@ describe('Onboarding', () => {
await expect(sdk.onboardingStatus()).resolves.toEqual({
status: 'ready',
hasTelemetry: 'none',
requiresAuthentication: false,
isAuthenticated: false,
hasBackend: true,
hasOnboardedKey: true,
hasBackup: false,
@@ -254,6 +275,8 @@ describe('Repository', () => {
await expect(sdk.onboardingStatus()).resolves.toEqual({
status: 'ready',
hasTelemetry: 'none',
requiresAuthentication: false,
isAuthenticated: false,
hasBackend: true,
hasOnboardedKey: true,
hasBackup: true,
@@ -521,6 +544,8 @@ describe('Schedule', () => {
await expect(sdk.onboardingStatus()).resolves.toEqual({
status: 'ready',
hasTelemetry: 'none',
requiresAuthentication: false,
isAuthenticated: false,
hasBackend: true,
hasOnboardedKey: true,
hasBackup: true,
@@ -10,6 +10,7 @@ const schema = z.object({
YUCCA_STATE_PATH: z.string().trim().min(1).default(resolve(homedir(), '.yucca')),
YUCCA_WELL_KNOWN_URL: optionalString,
YUCCA_UI_PATH: optionalString,
YUCCA_DISABLE_AUTH: z.stringbool().default(false),
});
export type Env = {
@@ -18,6 +19,7 @@ export type Env = {
statePath: string;
wellKnownUrl?: string;
uiPath?: string;
disableAuth: boolean;
};
export const readEnv = (source: NodeJS.ProcessEnv = process.env): Env => {
@@ -36,5 +38,6 @@ export const readEnv = (source: NodeJS.ProcessEnv = process.env): Env => {
statePath: resolve(parsed.YUCCA_STATE_PATH),
wellKnownUrl: parsed.YUCCA_WELL_KNOWN_URL,
uiPath: parsed.YUCCA_UI_PATH ? resolve(parsed.YUCCA_UI_PATH) : undefined,
disableAuth: parsed.YUCCA_DISABLE_AUTH,
};
};
@@ -21,6 +21,7 @@ async function bootstrap() {
useFactory: () => ({
statePath: env.statePath,
wellKnownUrl: env.wellKnownUrl,
requireSession: !env.disableAuth,
}),
}),
);
+79 -1
View File
@@ -107,6 +107,27 @@
]
}
},
"/api/auth/oidc/device/identity": {
"get": {
"operationId": "oidcDeviceFlowIdentity",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceFlowEventDto"
}
}
}
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/oidc/device": {
"get": {
"operationId": "oidcDeviceFlow",
@@ -132,7 +153,14 @@
],
"responses": {
"200": {
"description": ""
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceFlowEventDto"
}
}
}
}
},
"tags": [
@@ -873,6 +901,56 @@
"features"
]
},
"DeviceFlowEventType": {
"type": "string",
"enum": [
"START",
"SUCCESS",
"FAILURE"
]
},
"DeviceFlowFailureReason": {
"type": "string",
"enum": [
"UNKNOWN",
"EMAIL_NOT_ALLOWED",
"FEATURE_NOT_ENABLED"
]
},
"DeviceFlowEventDto": {
"type": "object",
"properties": {
"type": {
"allOf": [
{
"$ref": "#/components/schemas/DeviceFlowEventType"
}
]
},
"userCode": {
"type": "string"
},
"verificationUri": {
"type": "string"
},
"accessToken": {
"type": "string"
},
"userId": {
"type": "string"
},
"reason": {
"allOf": [
{
"$ref": "#/components/schemas/DeviceFlowFailureReason"
}
]
}
},
"required": [
"type"
]
},
"TicketAction": {
"type": "string",
"enum": [
+22 -1
View File
@@ -24,6 +24,16 @@ export type AuthDto = {
[key: string]: boolean;
};
};
export type DeviceFlowEventType = "START" | "SUCCESS" | "FAILURE";
export type DeviceFlowFailureReason = "UNKNOWN" | "EMAIL_NOT_ALLOWED" | "FEATURE_NOT_ENABLED";
export type DeviceFlowEventDto = {
"type": DeviceFlowEventType;
userCode?: string;
verificationUri?: string;
accessToken?: string;
userId?: string;
reason?: DeviceFlowFailureReason;
};
export type TicketAction = "repository.delete" | "repository.disable-worm";
export type TicketCreateRequestDto = {
action: TicketAction;
@@ -218,11 +228,22 @@ export function oidcCallback(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function oidcDeviceFlowIdentity(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: DeviceFlowEventDto;
}>("/api/auth/oidc/device/identity", {
...opts
}));
}
export function oidcDeviceFlow({ connectionType, connectionName }: {
connectionType?: string;
connectionName?: string;
} = {}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/api/auth/oidc/device${QS.query(QS.explode({
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: DeviceFlowEventDto;
}>(`/api/auth/oidc/device${QS.query(QS.explode({
connection_type: connectionType,
connection_name: connectionName
}))}`, {
@@ -3,7 +3,7 @@ import { ApiOkResponse, ApiQuery } from '@nestjs/swagger';
import { type Request, type Response } from 'express';
import { Duration } from 'luxon';
import { type Observable } from 'rxjs';
import { AuthDto } from 'src/dto/auth.dto';
import { AuthDto, DeviceFlowEventDto } from 'src/dto/auth.dto';
import { TicketCreateRequestDto, TicketCreateResponseDto, TicketDto } from 'src/dto/ticket.dto';
import { CookieName } from 'src/enum';
import { env } from 'src/env';
@@ -116,7 +116,14 @@ export class AuthController {
response.redirect(redirectTo);
}
@Sse('/oidc/device/identity')
@ApiOkResponse({ type: DeviceFlowEventDto })
oidcDeviceFlowIdentity(): Observable<MessageEvent> {
return this.auth.oidcDeviceFlowIdentityObservable();
}
@Sse('/oidc/device')
@ApiOkResponse({ type: DeviceFlowEventDto })
@ApiQuery({ name: 'connection_type', type: String, required: false, description: 'immich | standalone | restic' })
@ApiQuery({ name: 'connection_name', type: String, required: false, description: 'Instance name, e.g. a hostname' })
oidcDeviceFlow(
+21
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { DeviceFlowEventType, DeviceFlowFailureReason } from 'src/enum';
export class AuthDto {
@ApiProperty()
@@ -19,3 +20,23 @@ export class AuthDto {
@ApiProperty({ type: 'object', additionalProperties: { type: 'boolean' } })
features!: Record<string, boolean>;
}
export class DeviceFlowEventDto {
@ApiProperty({ enum: DeviceFlowEventType, enumName: 'DeviceFlowEventType' })
type!: DeviceFlowEventType;
@ApiProperty({ type: String, required: false })
userCode?: string;
@ApiProperty({ type: String, required: false })
verificationUri?: string;
@ApiProperty({ type: String, required: false })
accessToken?: string;
@ApiProperty({ type: String, required: false })
userId?: string;
@ApiProperty({ enum: DeviceFlowFailureReason, enumName: 'DeviceFlowFailureReason', required: false })
reason?: DeviceFlowFailureReason;
}
+12
View File
@@ -24,6 +24,18 @@ export enum MetadataKey {
Feature = 'FEATURE',
}
export enum DeviceFlowEventType {
Start = 'START',
Success = 'SUCCESS',
Failure = 'FAILURE',
}
export enum DeviceFlowFailureReason {
Unknown = 'UNKNOWN',
EmailNotAllowed = 'EMAIL_NOT_ALLOWED',
FeatureNotEnabled = 'FEATURE_NOT_ENABLED',
}
export enum DatabaseLock {
Migrations = 67,
}
@@ -313,7 +313,7 @@ describe(AuthService.name, () => {
});
it('binds the default connection for legacy clients (no connection params)', async () => {
await expect(sut.oidcDeviceFlow(jest.fn())).resolves.toEqual({ accessToken });
await expect(sut.oidcDeviceFlow(jest.fn())).resolves.toEqual({ accessToken, userId: mockUser.id });
expect(mocks.connection.getOrCreateDefault).toHaveBeenCalledWith(mockUser.id);
expect(mocks.connection.touchLastSeen).toHaveBeenCalledWith('default-connection');
@@ -336,7 +336,10 @@ describe(AuthService.name, () => {
mocks.connection.getByUserTypeName.mockResolvedValue(void 0);
mocks.connection.create.mockResolvedValue({ id: 'immich-home' } as never);
await expect(sut.oidcDeviceFlow(jest.fn(), 'immich', 'home-server')).resolves.toEqual({ accessToken });
await expect(sut.oidcDeviceFlow(jest.fn(), 'immich', 'home-server')).resolves.toEqual({
accessToken,
userId: mockUser.id,
});
expect(mocks.connection.create).toHaveBeenCalledWith({
userId: mockUser.id,
@@ -352,7 +355,10 @@ describe(AuthService.name, () => {
mocks.connection.getByUserTypeName.mockResolvedValue(void 0);
mocks.connection.create.mockResolvedValue({ id: 'standalone-nas' } as never);
await expect(sut.oidcDeviceFlow(jest.fn(), 'standalone', 'nas')).resolves.toEqual({ accessToken });
await expect(sut.oidcDeviceFlow(jest.fn(), 'standalone', 'nas')).resolves.toEqual({
accessToken,
userId: mockUser.id,
});
expect(mocks.connection.create).toHaveBeenCalledWith({
userId: mockUser.id,
@@ -369,7 +375,10 @@ describe(AuthService.name, () => {
mocks.connection.getByUserTypeName.mockResolvedValue(void 0);
mocks.connection.create.mockResolvedValue({ id: 'restic-connection' } as never);
await expect(sut.oidcDeviceFlow(jest.fn(), 'restic', 'my-laptop')).resolves.toEqual({ accessToken });
await expect(sut.oidcDeviceFlow(jest.fn(), 'restic', 'my-laptop')).resolves.toEqual({
accessToken,
userId: mockUser.id,
});
expect(mocks.connection.create).toHaveBeenCalledWith({ userId: mockUser.id, type: 'restic', name: 'my-laptop' });
expect(mocks.session.create).toHaveBeenCalledWith(
@@ -381,7 +390,10 @@ describe(AuthService.name, () => {
mocks.user.getFeatureOverrides.mockResolvedValue([{ flag: 'connection-restic', value: true }]);
mocks.connection.getByUserTypeName.mockResolvedValue({ id: 'existing-restic' } as never);
await expect(sut.oidcDeviceFlow(jest.fn(), 'restic', 'my-laptop')).resolves.toEqual({ accessToken });
await expect(sut.oidcDeviceFlow(jest.fn(), 'restic', 'my-laptop')).resolves.toEqual({
accessToken,
userId: mockUser.id,
});
expect(mocks.connection.create).not.toHaveBeenCalled();
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'existing-restic' }));
+54 -19
View File
@@ -10,7 +10,7 @@ import { UserInfoResponse } from 'openid-client';
import { from } from 'rxjs';
import { AuthDto } from 'src/dto/auth.dto';
import { TicketCreateRequestDto, TicketCreateResponseDto, TicketDto } from 'src/dto/ticket.dto';
import { CookieName, TicketAction } from 'src/enum';
import { CookieName, DeviceFlowEventType, DeviceFlowFailureReason, TicketAction } from 'src/enum';
import { env } from 'src/env';
import { ConnectionRepository } from 'src/repositories/connection.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -331,11 +331,7 @@ export class AuthService {
return connection.id;
}
async oidcDeviceFlow(
callback: (data: { userCode: string; verificationUri: string }) => void,
connectionType?: string,
connectionName?: string,
): Promise<{ accessToken: string }> {
private async runDeviceFlow(callback: (data: { userCode: string; verificationUri: string }) => void) {
const { userCode, verificationUri, claims: pendingClaims } = await this.oidc.deviceFlow();
callback({ userCode, verificationUri });
@@ -352,6 +348,24 @@ export class AuthService {
this.wideContext.addContext('customerId', user.id);
return user;
}
async oidcDeviceFlowIdentity(
callback: (data: { userCode: string; verificationUri: string }) => void,
): Promise<{ userId: string }> {
const user = await this.runDeviceFlow(callback);
return { userId: user.id };
}
async oidcDeviceFlow(
callback: (data: { userCode: string; verificationUri: string }) => void,
connectionType?: string,
connectionName?: string,
): Promise<{ accessToken: string; userId: string }> {
const user = await this.runDeviceFlow(callback);
const overrides = await this.user.getFeatureOverrides(user.id);
const connectionId = await this.resolveDeviceConnection(
user.id,
@@ -372,9 +386,24 @@ export class AuthService {
return {
accessToken,
userId: user.id,
};
}
oidcDeviceFlowIdentityObservable() {
return from(
new EventIterator<MessageEvent>(
(queue) =>
void this.oidcDeviceFlowIdentity((data) =>
queue.push({ data: { type: DeviceFlowEventType.Start, ...data } } as MessageEvent),
)
.then(({ userId }) => queue.push({ data: { type: DeviceFlowEventType.Success, userId } } as MessageEvent))
.catch((error) => this.pushDeviceFlowFailure(queue, error))
.finally(() => queue.stop()),
),
);
}
oidcDeviceFlowObservable(connectionType?: string, connectionName?: string) {
return from(
new EventIterator<MessageEvent>(
@@ -383,27 +412,33 @@ export class AuthService {
(data) =>
queue.push({
data: {
type: 'START',
type: DeviceFlowEventType.Start,
...data,
},
} as MessageEvent),
connectionType,
connectionName,
)
.then(({ accessToken }) => queue.push({ data: { type: 'SUCCESS', accessToken } } as MessageEvent))
.catch((error) => {
this.wideContext.setErrorCause(error);
this.logger.error('oidcDeviceFlow error:', error);
let reason = 'UNKNOWN';
if (error instanceof EmailNotAllowedException) {
reason = 'EMAIL_NOT_ALLOWED';
} else if (error instanceof FeatureNotEnabledException) {
reason = 'FEATURE_NOT_ENABLED';
}
queue.push({ data: { type: 'FAILURE', reason } } as MessageEvent);
})
.then(({ accessToken, userId }) =>
queue.push({ data: { type: DeviceFlowEventType.Success, accessToken, userId } } as MessageEvent),
)
.catch((error) => this.pushDeviceFlowFailure(queue, error))
.finally(() => queue.stop()),
),
);
}
private pushDeviceFlowFailure(queue: { push: (value: MessageEvent) => void }, error: unknown) {
this.wideContext.setErrorCause(error);
this.logger.error('oidcDeviceFlow error:', error);
let reason = DeviceFlowFailureReason.Unknown;
if (error instanceof EmailNotAllowedException) {
reason = DeviceFlowFailureReason.EmailNotAllowed;
} else if (error instanceof FeatureNotEnabledException) {
reason = DeviceFlowFailureReason.FeatureNotEnabled;
}
queue.push({ data: { type: DeviceFlowEventType.Failure, reason } } as MessageEvent);
}
}
@@ -507,10 +507,11 @@ describe('AuthController (e2e)', () => {
await fetch(approveUrl);
const successMessage = await firstValueFrom(replay.pipe(skip(1)));
const success = successMessage.data as { type: string; accessToken: string };
const success = successMessage.data as { type: string; accessToken: string; userId: string };
expect(success).toEqual({
type: 'SUCCESS',
accessToken: expect.any(String),
userId: expect.any(String),
});
await expect(testUtils.getUserBySub('device-flow-user')).resolves.toBeTruthy();
@@ -3,7 +3,7 @@
"paths": {
"/api/yucca/auth/oidc/device": {
"get": {
"operationId": "oidcDeviceFlow",
"operationId": "connectDeviceFlow",
"parameters": [],
"responses": {
"200": {
@@ -11,7 +11,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceFlowResponseDto"
"$ref": "#/components/schemas/DeviceFlowEventDto"
}
}
}
@@ -22,6 +22,51 @@
]
}
},
"/api/yucca/auth/session/device": {
"get": {
"operationId": "sessionDeviceFlow",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceFlowEventDto"
}
}
}
}
},
"tags": [
"Auth"
]
}
},
"/api/yucca/auth/session": {
"post": {
"operationId": "createSession",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateSessionRequestDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/api/yucca/auth/ticket": {
"post": {
"operationId": "createTicket",
@@ -1135,19 +1180,66 @@
],
"components": {
"schemas": {
"DeviceFlowResponseDto": {
"DeviceFlowEventType": {
"type": "string",
"enum": [
"START",
"SUCCESS",
"FAILURE"
]
},
"DeviceFlowFailureReason": {
"type": "string",
"enum": [
"NOT_CONNECTED",
"DEVICE_FLOW_FAILED",
"WRONG_ACCOUNT",
"UNKNOWN"
]
},
"DeviceFlowEventDto": {
"type": "object",
"properties": {
"type": {
"allOf": [
{
"$ref": "#/components/schemas/DeviceFlowEventType"
}
]
},
"userCode": {
"type": "string"
},
"verificationUri": {
"type": "string"
},
"token": {
"type": "string"
},
"backendId": {
"type": "string"
},
"reason": {
"allOf": [
{
"$ref": "#/components/schemas/DeviceFlowFailureReason"
}
]
}
},
"required": [
"userCode",
"verificationUri"
"type"
]
},
"CreateSessionRequestDto": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
},
"required": [
"token"
]
},
"TicketAction": {
@@ -1556,6 +1648,12 @@
}
]
},
"requiresAuthentication": {
"type": "boolean"
},
"isAuthenticated": {
"type": "boolean"
},
"hasOnboardedKey": {
"type": "boolean"
},
@@ -1575,6 +1673,8 @@
"required": [
"status",
"hasTelemetry",
"requiresAuthentication",
"isAuthenticated",
"hasOnboardedKey",
"hasBackend",
"hasBackup",
@@ -28,6 +28,7 @@
"dependencies": {
"@futo-org/restic-wrapper": "catalog:",
"@nestjs/event-emitter": "catalog:",
"@nestjs/jwt": "catalog:",
"better-sqlite3": "catalog:",
"class-validator": "catalog:",
"cookie": "catalog:",
@@ -1,3 +1,4 @@
export const ORCHESTRATION_PORT = 22_676;
export const REPOSITORY_DEFAULT_CLOUD_UUID = 'd0368cdd-39ae-40e1-91d6-d81815c65c7e';
export const YUCCA_WELL_KNOWN = 'https://meta.futo.cloud/.well-known/yucca.json';
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -1,17 +1,51 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { DeviceFlowResponseDto } from '../dto/auth.dto';
import { Body, Controller, Post, Req, Res, Sse } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import { type Request, type Response } from 'express';
import { SESSION_TTL_MS } from '../const';
import { CreateSessionRequestDto, DeviceFlowEventDto } from '../dto/auth.dto';
import { TicketCreateRequestDto, TicketCreateResponseDto } from '../dto/ticket.dto';
import { CookieName } from '../enum';
import { PublicRoute } from '../middleware/session.guard';
import { AuthService } from '../services/auth.service';
import { SessionService } from '../services/session.service';
@Controller('/yucca/auth')
export class AuthController {
constructor(readonly auth: AuthService) {}
constructor(
readonly auth: AuthService,
readonly session: SessionService,
) {}
@Get('/oidc/device')
@ApiOkResponse({ type: DeviceFlowResponseDto })
oidcDeviceFlow(): Promise<DeviceFlowResponseDto> {
return this.auth.oidcDeviceFlow();
@Sse('/oidc/device')
@ApiOkResponse({ type: DeviceFlowEventDto })
connectDeviceFlow() {
return this.auth.deviceFlow(false);
}
@Sse('/session/device')
@PublicRoute()
@ApiOkResponse({ type: DeviceFlowEventDto })
sessionDeviceFlow() {
return this.auth.deviceFlow(true);
}
@Post('/session')
@PublicRoute()
@ApiBody({ type: CreateSessionRequestDto })
async createSession(
@Body() dto: CreateSessionRequestDto,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
await this.session.authenticate(dto.token);
response.cookie(CookieName.SessionToken, dto.token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: request.protocol === 'https',
maxAge: SESSION_TTL_MS,
});
}
@Post('/ticket')
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Post, Put, Req } from '@nestjs/common';
import { ApiBody, ApiOkResponse } from '@nestjs/swagger';
import {
CurrentRecoveryKeyResponse,
ImportRecoveryKeyRequest,
OnboardingStatusResponseDto,
} from '../dto/onboarding.dto';
import { PublicRoute, type SessionRequest } from '../middleware/session.guard';
import { OnboardingService } from '../services/onboarding.service';
@Controller('/yucca/onboarding')
@@ -12,9 +13,10 @@ export class OnboardingController {
constructor(readonly service: OnboardingService) {}
@Get()
@PublicRoute()
@ApiOkResponse({ type: OnboardingStatusResponseDto })
async onboardingStatus(): Promise<OnboardingStatusResponseDto> {
return this.service.onboardingStatus();
async onboardingStatus(@Req() request: SessionRequest): Promise<OnboardingStatusResponseDto> {
return this.service.onboardingStatus(request.session);
}
@Get('/recovery-key')
@@ -1,9 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { DeviceFlowEventType, DeviceFlowFailureReason } from '../enum';
export class DeviceFlowResponseDto {
@ApiProperty()
userCode!: string;
export class DeviceFlowEventDto {
@ApiProperty({ enum: DeviceFlowEventType, enumName: 'DeviceFlowEventType' })
type!: DeviceFlowEventType;
@ApiProperty({ type: String, required: false })
userCode?: string;
@ApiProperty({ type: String, required: false })
verificationUri?: string;
@ApiProperty({ type: String, required: false })
token?: string;
@ApiProperty({ type: String, required: false })
backendId?: string;
@ApiProperty({ enum: DeviceFlowFailureReason, enumName: 'DeviceFlowFailureReason', required: false })
reason?: DeviceFlowFailureReason;
}
export class CreateSessionRequestDto {
@ApiProperty()
verificationUri!: string;
@IsString()
token!: string;
}
@@ -12,6 +12,12 @@ export class OnboardingStatusResponseDto {
@ApiProperty({ enum: TelemetryLevel, enumName: 'TelemetryLevel' })
hasTelemetry!: TelemetryLevel;
@ApiProperty({ type: Boolean })
requiresAuthentication!: boolean;
@ApiProperty({ type: Boolean })
isAuthenticated!: boolean;
@ApiProperty({ type: Boolean })
hasOnboardedKey!: boolean;
@@ -2,6 +2,7 @@ export enum CookieName {
NextUrl = 'sdk-next',
OidcState = 'sdk-oidc-state',
OidcCodeVerifier = 'sdk-oidc-code-verifier',
SessionToken = 'sdk-session',
YuccaAccessToken = 'yucca-access-token',
YuccaOidcState = 'yucca-oidc-state',
YuccaOidcCodeVerifier = 'yucca-oidc-code-verifier',
@@ -22,6 +23,11 @@ export enum ConfigurationKey {
Telemetry = 'telemetry',
SkippedOnboardingExtraConfig = 'skipped-onboarding-extra-config',
ResticOptionRestConnections = 'restic-o-rest-connections',
SessionSecret = 'session-secret',
}
export enum MetadataKey {
PublicRoute = 'public-route',
}
export enum BackendType {
@@ -48,6 +54,19 @@ export enum InternalEvent {
ModuleConfigUpdated = 'yucca.moduleConfig.updated',
}
export enum DeviceFlowEventType {
Start = 'START',
Success = 'SUCCESS',
Failure = 'FAILURE',
}
export enum DeviceFlowFailureReason {
NotConnected = 'NOT_CONNECTED',
DeviceFlowFailed = 'DEVICE_FLOW_FAILED',
WrongAccount = 'WRONG_ACCOUNT',
Unknown = 'UNKNOWN',
}
export enum BootstrapStatus {
NotReady = 'not-ready',
Ready = 'ready',
@@ -6,6 +6,7 @@ import { LocalRepositoryDto, RunDto } from '../dto/repository.dto';
import { RunningTaskDto } from '../dto/runningTasks.dto';
import { ScheduleDto } from '../dto/schedule.dto';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
import { SessionService } from '../services/session.service';
export type GatewayEvent =
| {
@@ -64,9 +65,6 @@ export type GatewayEvent =
runId: string;
repositoryId: string;
run: Partial<RunDto>;
}
| {
type: 'DeviceFlowFailure';
};
@WebSocketGateway({
@@ -75,7 +73,10 @@ export type GatewayEvent =
transports: ['websocket'],
})
export class EventsGateway implements OnGatewayConnection {
constructor(private readonly moduleConfig: ModuleConfigRepository) {}
constructor(
private readonly moduleConfig: ModuleConfigRepository,
private readonly session: SessionService,
) {}
@WebSocketServer()
server?: Server;
@@ -108,6 +109,16 @@ export class EventsGateway implements OnGatewayConnection {
throw new Error('Auth function not set');
}
const configuration = await this.session.cloudConfiguration();
if (this.session.isRequired(configuration)) {
const session = await this.session.fromCookieHeader(client.handshake.headers.cookie, configuration);
if (!session) {
throw new Error('No session cookie.');
}
}
return {
user: {
isAdmin: true,
@@ -0,0 +1,40 @@
import { CanActivate, ExecutionContext, Injectable, SetMetadata, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MetadataKey } from '../enum';
import { Session, SessionService } from '../services/session.service';
export const PublicRoute = (): MethodDecorator => SetMetadata(MetadataKey.PublicRoute, true);
export interface SessionRequest extends Request {
session?: Session;
}
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly session: SessionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<SessionRequest>();
const configuration = await this.session.cloudConfiguration();
request.session = await this.session.fromCookieHeader(request.headers.cookie, configuration);
const isPublic = this.reflector.getAllAndOverride<boolean | undefined>(MetadataKey.PublicRoute, [
context.getHandler(),
context.getClass(),
]);
if (isPublic || !this.session.isRequired(configuration)) {
return true;
}
if (!request.session) {
throw new UnauthorizedException('Log in to FUTO Backups to manage this instance');
}
return true;
}
}
@@ -30,6 +30,7 @@ export type ModuleConfig = {
wellKnownUrl?: string;
externalBaseUrl?: string;
requireWsAuth?: boolean;
requireSession?: boolean;
requireLock?: boolean;
developmentMode?: boolean;
@@ -1,6 +1,7 @@
import { DynamicModule, FactoryProvider, Module, ModuleMetadata } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { JwtModule } from '@nestjs/jwt';
import { ScheduleModule } from '@nestjs/schedule';
import Database from 'better-sqlite3';
import { SqliteDialect } from 'kysely';
@@ -20,6 +21,7 @@ import { RunningTasksController } from './controllers/runningTasks.controller';
import { ScheduleController } from './controllers/schedule.controller';
import { EventsGateway } from './events/events.gateway';
import { TelemetryErrorInterceptor } from './interceptors/telemetry-error.interceptor';
import { SessionGuard } from './middleware/session.guard';
import { type ModuleConfig, ModuleConfigProvider } from './moduleConfig';
import { BackendRepository } from './repositories/backend.repository';
import { BootstrapRepository } from './repositories/bootstrap.repository';
@@ -47,6 +49,7 @@ import { RepositoryService } from './services/repository.service';
import { RunHistoryService } from './services/runHistory.service';
import { RunningTasksService } from './services/runningTasks.service';
import { ScheduleService } from './services/schedule.service';
import { SessionService } from './services/session.service';
import { TelemetryService } from './services/telemetry.service';
import { YuccaService } from './services/yucca.service';
import { yuccaWellKnown } from './wellKnown';
@@ -94,6 +97,7 @@ export const services = [
RunHistoryService,
RunningTasksService,
ScheduleService,
SessionService,
TelemetryService,
YuccaService,
];
@@ -153,11 +157,13 @@ export class OrchestrationApiModule {
},
}),
EventEmitterModule.forRoot(),
JwtModule.register({}),
ScheduleModule.forRoot(),
],
controllers,
providers: [
{ provide: APP_INTERCEPTOR, useClass: TelemetryErrorInterceptor },
{ provide: APP_GUARD, useClass: SessionGuard },
EventsGateway,
...repositories,
...services,
@@ -19,6 +19,12 @@ export class ConfigRepository {
if (!hasKey) {
await this.set(ConfigurationKey.EncryptionKey, randomBytes(32).toString('hex'));
}
const hasSecret = await this.hasSessionSecret();
if (!hasSecret) {
await this.set(ConfigurationKey.SessionSecret, randomBytes(32).toString('hex'));
}
}
private async set(key: ConfigurationKey, value: string) {
@@ -114,6 +120,14 @@ export class ConfigRepository {
return this.set(ConfigurationKey.SkippedOnboardingExtraConfig, '1');
}
async hasSessionSecret() {
return this.has(ConfigurationKey.SessionSecret);
}
async getSessionSecret(): Promise<Buffer> {
return Buffer.from(await this.get(ConfigurationKey.SessionSecret), 'hex');
}
async getResticOptions(
placement: ResticPlacement,
): Promise<{ connections: number; packSizeMib: number | undefined }> {
@@ -12,6 +12,7 @@ export type BackendConfiguration =
type: BackendType.Yucca;
url?: string;
accessToken: string;
userId?: string;
}
| {
/**
@@ -1,16 +1,25 @@
import { adoptRepositories, getAuth, TicketCreateRequestDto } from '@futo-org/backups-api-client';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import {
adoptRepositories,
getAuth,
TicketCreateRequestDto,
type DeviceFlowEventDto as UpstreamDeviceFlowEvent,
} from '@futo-org/backups-api-client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventIterator } from 'event-iterator';
import { createEventSource, EventSourceClient } from 'eventsource-client';
import { hostname } from 'node:os';
import { from } from 'rxjs';
import { REPOSITORY_DEFAULT_CLOUD_UUID } from '../const';
import { DeviceFlowEventDto } from '../dto/auth.dto';
import { TicketCreateResponseDto } from '../dto/ticket.dto';
import { BackendType, CookieName } from '../enum';
import { BackendType, CookieName, DeviceFlowEventType, DeviceFlowFailureReason } from '../enum';
import { EventsGateway } from '../events/events.gateway';
import { BackendRepository } from '../repositories/backend.repository';
import { ConfigRepository } from '../repositories/config.repository';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
import { RepositoryRepository } from '../repositories/repository.repository';
import { yuccaWellKnown } from '../wellKnown';
import { SessionService } from './session.service';
import { TelemetryService } from './telemetry.service';
@Injectable()
@@ -22,6 +31,7 @@ export class AuthService {
readonly events: EventsGateway,
readonly telemetry: TelemetryService,
readonly repository: RepositoryRepository,
readonly session: SessionService,
) {}
private connectionType(): string {
@@ -72,57 +82,49 @@ export class AuthService {
}
}
private async waitForDeviceFlow(events: EventSourceClient, overrideEndpoint: string | undefined, endpoint: string) {
for await (const { data } of events) {
const { type, accessToken } = JSON.parse(data);
private async fetchBackendUserId(endpoint: string, accessToken: string): Promise<string> {
const auth = await getAuth({
baseUrl: endpoint,
headers: { cookie: `${CookieName.YuccaAccessToken}=${accessToken}` },
});
switch (type) {
case 'SUCCESS': {
await this.backend.updateBackend(REPOSITORY_DEFAULT_CLOUD_UUID, {
type: BackendType.Yucca,
accessToken,
url: overrideEndpoint,
});
await this.adoptOwnRepositories(endpoint, accessToken);
this.telemetry.submitStructuredLog('Connected FUTO Backups backend', {
backendId: REPOSITORY_DEFAULT_CLOUD_UUID,
});
this.events.publish({
type: 'BackendCreate',
backend: {
id: REPOSITORY_DEFAULT_CLOUD_UUID,
type: BackendType.Yucca,
description: 'FUTO Backups',
isOnline: true,
},
});
break;
}
case 'FAILURE': {
this.telemetry.submitStructuredLog('Device flow authentication failed', {});
this.events.publish({
type: 'DeviceFlowFailure',
});
break;
}
}
}
events.close();
return auth.id;
}
async oidcDeviceFlow(): Promise<{ userCode: string; verificationUri: string }> {
const endpoint = await yuccaWellKnown.getBaseUrl();
private async connectBackend(endpoint: string, accessToken: string, userId: string): Promise<void> {
await this.backend.updateBackend(REPOSITORY_DEFAULT_CLOUD_UUID, {
type: BackendType.Yucca,
accessToken,
userId,
});
const url = new URL('/api/auth/oidc/device', endpoint);
url.searchParams.set('connection_type', this.connectionType());
url.searchParams.set('connection_name', this.connectionName());
await this.adoptOwnRepositories(endpoint, accessToken);
this.telemetry.submitStructuredLog('Connected FUTO Backups backend', {
backendId: REPOSITORY_DEFAULT_CLOUD_UUID,
});
this.events.publish({
type: 'BackendCreate',
backend: {
id: REPOSITORY_DEFAULT_CLOUD_UUID,
type: BackendType.Yucca,
description: 'FUTO Backups',
isOnline: true,
},
});
}
private async relayDeviceFlow(
identity: boolean,
endpoint: string,
publish: (event: DeviceFlowEventDto) => void,
): Promise<UpstreamDeviceFlowEvent | undefined> {
const url = new URL(identity ? '/api/auth/oidc/device/identity' : '/api/auth/oidc/device', endpoint);
if (!identity) {
url.searchParams.set('connection_type', this.connectionType());
url.searchParams.set('connection_name', this.connectionName());
}
const events: EventSourceClient = createEventSource({
url,
@@ -131,22 +133,103 @@ export class AuthService {
const connectTimeout = setTimeout(() => events.close(), 10_000);
for await (const { data } of events) {
try {
for await (const { data } of events) {
clearTimeout(connectTimeout);
const message = JSON.parse(data) as UpstreamDeviceFlowEvent;
if (message.type === 'START') {
publish({
type: DeviceFlowEventType.Start,
userCode: message.userCode,
verificationUri: message.verificationUri,
});
continue;
}
if (message.type === 'SUCCESS') {
return message;
}
if (message.type === 'FAILURE') {
this.telemetry.submitStructuredLog('Device flow authentication failed', { reason: message.reason });
return;
}
}
} finally {
clearTimeout(connectTimeout);
const { userCode, verificationUri } = JSON.parse(data);
events.close();
}
}
void this.waitForDeviceFlow(events, undefined, endpoint).catch((error) => {
this.telemetry.submitStructuredLog('Device flow authentication errored', { error });
this.events.publish({ type: 'DeviceFlowFailure' });
});
return {
userCode,
verificationUri,
};
private async runDeviceFlow(identity: boolean, publish: (event: DeviceFlowEventDto) => void): Promise<void> {
const cloud = await this.session.cloudConfiguration();
if (identity && !cloud) {
publish({ type: DeviceFlowEventType.Failure, reason: DeviceFlowFailureReason.NotConnected });
return;
}
throw new InternalServerErrorException('Failed to start authentication with FUTO Backups');
const endpoint = await yuccaWellKnown.getBaseUrl();
const upstream = await this.relayDeviceFlow(identity, endpoint, publish);
if (!upstream) {
publish({ type: DeviceFlowEventType.Failure, reason: DeviceFlowFailureReason.DeviceFlowFailed });
return;
}
if (!upstream.userId) {
throw new Error('Device flow succeeded without a user');
}
const userId = upstream.userId;
let backendId: string | undefined;
if (identity) {
const configuration = cloud!;
let claimedUserId = configuration.userId;
if (!claimedUserId) {
claimedUserId = await this.fetchBackendUserId(endpoint, configuration.accessToken);
await this.backend.updateBackend(REPOSITORY_DEFAULT_CLOUD_UUID, { ...configuration, userId: claimedUserId });
}
if (userId !== claimedUserId) {
publish({ type: DeviceFlowEventType.Failure, reason: DeviceFlowFailureReason.WrongAccount });
return;
}
} else {
const { accessToken } = upstream;
if (!accessToken) {
throw new Error('Device flow succeeded without an access token');
}
backendId = REPOSITORY_DEFAULT_CLOUD_UUID;
await this.connectBackend(endpoint, accessToken, userId);
}
const configuration = await this.session.cloudConfiguration();
const isRequired = this.session.isRequired(configuration);
publish({
type: DeviceFlowEventType.Success,
token: isRequired ? await this.session.issue(userId) : undefined,
backendId,
});
}
deviceFlow(identity: boolean) {
return from(
new EventIterator<MessageEvent>(
(queue) =>
void this.runDeviceFlow(identity, (event) => queue.push({ data: event } as MessageEvent))
.catch((error) => {
this.telemetry.submitStructuredLog('Device flow authentication errored', { error });
queue.push({
data: { type: DeviceFlowEventType.Failure, reason: DeviceFlowFailureReason.Unknown },
} as MessageEvent);
})
.finally(() => queue.stop()),
),
);
}
async createTicket(dto: TicketCreateRequestDto): Promise<TicketCreateResponseDto> {
@@ -6,6 +6,7 @@ import { BootstrapRepository } from '../repositories/bootstrap.repository';
import { ConfigRepository } from '../repositories/config.repository';
import { RepositoryRepository } from '../repositories/repository.repository';
import { ScheduleRepository } from '../repositories/schedule.repository';
import { Session, SessionService } from './session.service';
import { TelemetryService } from './telemetry.service';
@Injectable()
@@ -17,10 +18,14 @@ export class OnboardingService {
private readonly config: ConfigRepository,
private readonly bootstrap: BootstrapRepository,
private readonly telemetry: TelemetryService,
private readonly session: SessionService,
) {}
async onboardingStatus(): Promise<OnboardingStatusResponseDto> {
async onboardingStatus(session?: Session): Promise<OnboardingStatusResponseDto> {
const status = this.bootstrap.getStatus();
const configuration = await this.session.cloudConfiguration();
const requiresAuthentication = this.session.isRequired(configuration);
const isAuthenticated = session !== undefined;
if (status !== BootstrapStatus.Ready) {
let error = this.bootstrap.getError();
@@ -30,6 +35,8 @@ export class OnboardingService {
status,
error,
hasTelemetry: TelemetryLevel.None,
requiresAuthentication,
isAuthenticated,
hasOnboardedKey: false,
hasBackend: false,
hasBackup: false,
@@ -45,6 +52,8 @@ export class OnboardingService {
return {
status: BootstrapStatus.Ready,
hasTelemetry: (await this.config.hasTelemetry()) ? TelemetryLevel.Full : TelemetryLevel.None,
requiresAuthentication,
isAuthenticated,
hasOnboardedKey: await this.config.hasOnboardedKey(),
hasBackend: backends.length > 0,
hasBackup: repositories.length > 0,
@@ -0,0 +1,60 @@
import { JwtService } from '@nestjs/jwt';
import { SessionService } from './session.service';
jest.mock('@futo-org/backups-api-client', () => ({}));
describe(SessionService.name, () => {
const userId = 'a06b5b4e-3d21-4d3f-9d4b-c0ffee000001';
const makeService = (overrides: { requireSession?: boolean; connected?: boolean; claimedUserId?: string } = {}) => {
const config = {
getSessionSecret: jest.fn().mockResolvedValue(Buffer.alloc(32, 1)),
};
const moduleConfig = { get: () => ({ requireSession: overrides.requireSession ?? true }) };
const connected = overrides.connected ?? true;
const configuration = connected
? { type: 'yucca', userId: 'claimedUserId' in overrides ? overrides.claimedUserId : userId }
: undefined;
const backend = { getBackend: jest.fn().mockResolvedValue(connected ? { configuration } : undefined) };
return {
service: new SessionService(config as never, moduleConfig as never, backend as never, new JwtService()),
config,
backend,
configuration: configuration as never,
};
};
it('verifies a token it issued', async () => {
const { service, configuration } = makeService();
const token = await service.issue(userId);
await expect(service.verify(token, configuration)).resolves.toEqual({ userId });
});
it('rejects a token signed with a different secret', async () => {
const { service, config, configuration } = makeService();
const token = await service.issue(userId);
config.getSessionSecret.mockResolvedValue(Buffer.alloc(32, 2));
await expect(service.verify(token, configuration)).resolves.toBeUndefined();
});
it('is required once the cloud backend is connected, even without a recorded account', () => {
const { service, configuration } = makeService({ claimedUserId: void 0 });
expect(service.isRequired(configuration)).toBe(true);
});
it('is not required without a cloud backend', () => {
const { service, configuration } = makeService({ connected: false });
expect(service.isRequired(configuration)).toBe(false);
});
it('is not required when the host does not opt in', () => {
const { service, configuration } = makeService({ requireSession: false });
expect(service.isRequired(configuration)).toBe(false);
});
});
@@ -0,0 +1,91 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { parse as parseCookies } from 'cookie';
import { REPOSITORY_DEFAULT_CLOUD_UUID, SESSION_TTL_MS } from '../const';
import { BackendType, CookieName } from '../enum';
import { BackendRepository } from '../repositories/backend.repository';
import { ConfigRepository } from '../repositories/config.repository';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
import { BackendConfiguration } from '../schema/tables/backend.table';
export type CloudConfiguration = BackendConfiguration & { type: BackendType.Yucca };
export type Session = {
userId: string;
};
@Injectable()
export class SessionService {
constructor(
private readonly config: ConfigRepository,
private readonly moduleConfig: ModuleConfigRepository,
private readonly backend: BackendRepository,
private readonly jwt: JwtService,
) {}
async cloudConfiguration(): Promise<CloudConfiguration | undefined> {
const cloud = await this.backend.getBackend(REPOSITORY_DEFAULT_CLOUD_UUID);
return cloud?.configuration.type === BackendType.Yucca ? cloud.configuration : undefined;
}
isRequired(configuration: CloudConfiguration | undefined): boolean {
if (!this.moduleConfig.get().requireSession) {
return false;
}
return configuration !== undefined;
}
async issue(userId: string): Promise<string> {
return this.jwt.signAsync(
{},
{
secret: await this.signingKey(),
subject: userId,
expiresIn: SESSION_TTL_MS / 1000,
},
);
}
async verify(token: string | undefined, configuration: CloudConfiguration | undefined): Promise<Session | undefined> {
const claimedUserId = configuration?.userId;
if (!token || !claimedUserId) {
return;
}
try {
await this.jwt.verifyAsync(token, { secret: await this.signingKey(), subject: claimedUserId });
} catch {
return;
}
return { userId: claimedUserId };
}
async authenticate(token: string): Promise<Session> {
const configuration = await this.cloudConfiguration();
const session = await this.verify(token, configuration);
if (!session) {
throw new UnauthorizedException('Session token is invalid or expired');
}
return session;
}
async fromCookieHeader(
header: string | undefined,
configuration: CloudConfiguration | undefined,
): Promise<Session | undefined> {
if (!header) {
return;
}
return this.verify(parseCookies(header)[CookieName.SessionToken], configuration);
}
private async signingKey(): Promise<Buffer> {
return this.config.getSessionSecret();
}
}
@@ -1,5 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { JwtModule } from '@nestjs/jwt';
import { ScheduleModule } from '@nestjs/schedule';
import { Test, TestingModule } from '@nestjs/testing';
import Database from 'better-sqlite3';
@@ -73,6 +74,7 @@ export async function createTestingModule(): Promise<TestContext> {
},
]),
EventEmitterModule.forRoot(),
JwtModule.register({}),
ScheduleModule.forRoot(),
],
controllers,
@@ -1,26 +1,21 @@
<script lang="ts">
import Suspense from "$lib/components/util/Suspense.svelte";
import DeviceFlowAction from "$lib/components/util/DeviceFlowAction.svelte";
import DeviceFlowCode from "$lib/components/util/DeviceFlowCode.svelte";
import type { SocketEvent } from "$lib/events";
import { type BackendDto } from "$lib/fetch-client";
import {
useBackendEventHandler,
useDeviceFlow,
} from "$lib/services/backend.service";
import { useBackendEventHandler } from "$lib/services/backend.service";
import { createDeviceFlow } from "$lib/services/deviceFlow.service.svelte";
import { useCreateSession } from "$lib/services/session.service";
import {
Button,
Code,
HStack,
IconButton,
LoadingSpinner,
Modal,
ModalBody,
ModalFooter,
Stack,
Text,
toastManager,
VStack,
} from "@immich/ui";
import { mdiContentCopy } from "@mdi/js";
import { onDestroy } from "svelte";
import OnEvents from "../../util/OnEvents.svelte";
type Props = {
@@ -30,67 +25,47 @@
let { onCreate, onClose }: Props = $props();
const uid = $props.id();
const query = useDeviceFlow(uid);
const { onBackendCreate: onBackendCreateHandler } = useBackendEventHandler();
const session = useCreateSession();
const flow = createDeviceFlow("oidc", {
createSession: (token) => session.mutateAsync(token),
onComplete: (event) => {
if (event.backendId) {
onCreate?.(event.backendId);
}
onClose();
},
});
flow.start();
onDestroy(flow.stop);
function onBackendCreate(event: SocketEvent<{ backend: BackendDto }>) {
onBackendCreateHandler(event);
onCreate?.(event.data.backend.id);
onClose();
}
function onDeviceFlowFailure() {
toastManager.danger("Failed to log into FUTO Backups");
onClose();
}
function onRetry() {
query.refetch();
}
function onCopy() {
navigator.clipboard.writeText(query.data!.userCode);
}
</script>
<OnEvents {onBackendCreate} {onDeviceFlowFailure} />
<OnEvents {onBackendCreate} />
<Modal title="Logging into FUTO Backups" icon={false} {onClose}>
<ModalBody>
<Suspense {query}>
<VStack>
<Text>You may be asked or shown the following code:</Text>
<Stack direction="row" align="center">
<Code class="text-3xl select-all">{query.data!.userCode}</Code>
<IconButton
color="secondary"
variant="outline"
icon={mdiContentCopy}
onclick={onCopy}
aria-label="Copy code"
/>
</Stack>
<HStack class="mt-4">
<LoadingSpinner />
<Text>Waiting for you to confirm login...</Text>
</HStack>
</VStack>
</Suspense>
{#if flow.state.userCode}
<DeviceFlowCode {flow} />
{:else if flow.state.error}
<Text color="danger">{flow.state.error}</Text>
{:else}
<LoadingSpinner />
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={onClose}>
Cancel
</Button>
<Button
shape="round"
fullWidth
onclick={onRetry}
disabled={query.isFetching}>Try again</Button
>
<DeviceFlowAction {flow} />
</HStack>
</ModalFooter>
</Modal>
@@ -4,6 +4,7 @@
import { LoadingSpinner } from "@immich/ui";
import { onMount, type Snippet } from "svelte";
import OnboardingBootstrapError from "./OnboardingBootstrapError.svelte";
import OnboardingLogin from "./OnboardingLogin.svelte";
import SampleOnboarding from "./SampleOnboarding.svelte";
type Props = {
@@ -23,6 +24,8 @@
function onSkip() {
onboarding = {
status: "ready",
requiresAuthentication: false,
isAuthenticated: true,
hasTelemetry: "full",
hasBackend: true,
hasOnboardedKey: true,
@@ -37,6 +40,10 @@
<LoadingSpinner />
{:else if onboarding.status === "error"}
<OnboardingBootstrapError error={onboarding.error} onQuit={onExit} />
{:else if onboarding.requiresAuthentication && !onboarding.isAuthenticated}
<OnboardingLogin
onAuthenticated={() => (onboarding!.isAuthenticated = true)}
/>
{:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey)}
<SampleOnboarding
status={onboarding}
@@ -0,0 +1,64 @@
<script lang="ts">
import DeviceFlowAction from "$lib/components/util/DeviceFlowAction.svelte";
import DeviceFlowCode from "$lib/components/util/DeviceFlowCode.svelte";
import { createDeviceFlow } from "$lib/services/deviceFlow.service.svelte";
import { useCreateSession } from "$lib/services/session.service";
import {
Button,
HStack,
Modal,
ModalBody,
ModalFooter,
Stack,
Text,
} from "@immich/ui";
import { onDestroy } from "svelte";
type Props = {
onAuthenticated: () => void;
};
const { onAuthenticated }: Props = $props();
const session = useCreateSession();
const flow = createDeviceFlow("session", {
createSession: (token) => session.mutateAsync(token),
onComplete: () => onAuthenticated(),
});
onDestroy(flow.stop);
</script>
<Modal title="Log in to FUTO Backups" icon={false} onClose={() => {}}>
<ModalBody>
{#if flow.state.userCode}
<DeviceFlowCode {flow} />
{:else}
<Stack gap={4}>
<Text>
This instance is connected to a FUTO Backups account. Log in with that
account to manage it.
</Text>
{#if flow.state.error}
<Text color="danger">{flow.state.error}</Text>
{/if}
</Stack>
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
{#if flow.state.userCode}
<DeviceFlowAction {flow} />
{:else}
<Button
shape="round"
fullWidth
loading={flow.state.pending}
onclick={flow.start}>Log in with FUTO</Button
>
{/if}
</HStack>
</ModalFooter>
</Modal>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { DeviceFlow } from "$lib/services/deviceFlow.service.svelte";
import { Button } from "@immich/ui";
type Props = {
flow: DeviceFlow;
};
const { flow }: Props = $props();
</script>
{#if flow.state.opened}
<Button shape="round" fullWidth onclick={flow.start}>Try again</Button>
{:else}
<Button
shape="round"
fullWidth
disabled={!flow.state.verificationUri}
onclick={flow.open}>Continue to login</Button
>
{/if}
@@ -0,0 +1,36 @@
<script lang="ts">
import type { DeviceFlow } from "$lib/services/deviceFlow.service.svelte";
import { Code, HStack, IconButton, LoadingSpinner, Stack, Text } from "@immich/ui";
import { mdiContentCopy } from "@mdi/js";
type Props = {
flow: DeviceFlow;
};
const { flow }: Props = $props();
function onCopy() {
navigator.clipboard.writeText(flow.state.userCode!);
}
</script>
<Stack gap={4}>
<Text>You may be asked or shown the following code:</Text>
<Stack direction="row" align="center">
<Code class="text-3xl select-all">{flow.state.userCode}</Code>
<IconButton
color="secondary"
variant="outline"
icon={mdiContentCopy}
onclick={onCopy}
aria-label="Copy code"
/>
</Stack>
{#if flow.state.opened}
<HStack>
<LoadingSpinner />
<Text>Waiting for you to confirm login...</Text>
</HStack>
{/if}
</Stack>
@@ -14,9 +14,18 @@ const oazapfts = Oazapfts.runtime(defaults);
export const servers = {
server1: "http://localhost:22676"
};
export type DeviceFlowResponseDto = {
userCode: string;
verificationUri: string;
export type DeviceFlowEventType = "START" | "SUCCESS" | "FAILURE";
export type DeviceFlowFailureReason = "NOT_CONNECTED" | "DEVICE_FLOW_FAILED" | "WRONG_ACCOUNT" | "UNKNOWN";
export type DeviceFlowEventDto = {
"type": DeviceFlowEventType;
userCode?: string;
verificationUri?: string;
token?: string;
backendId?: string;
reason?: DeviceFlowFailureReason;
};
export type CreateSessionRequestDto = {
token: string;
};
export type TicketAction = "repository.delete" | "repository.disable-worm";
export type TicketCreateRequestDto = {
@@ -109,6 +118,8 @@ export type OnboardingStatusResponseDto = {
status: BootstrapStatus;
error?: string;
hasTelemetry: TelemetryLevel;
requiresAuthentication: boolean;
isAuthenticated: boolean;
hasOnboardedKey: boolean;
hasBackend: boolean;
hasBackup: boolean;
@@ -289,14 +300,29 @@ export type ScheduleUpdateRequestDto = {
export type ScheduleUpdateResponseDto = {
schedule: ScheduleDto;
};
export function oidcDeviceFlow(opts?: Oazapfts.RequestOpts) {
export function connectDeviceFlow(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: DeviceFlowResponseDto;
data: DeviceFlowEventDto;
}>("/api/yucca/auth/oidc/device", {
...opts
}));
}
export function sessionDeviceFlow(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: DeviceFlowEventDto;
}>("/api/yucca/auth/session/device", {
...opts
}));
}
export function createSession(createSessionRequestDto: CreateSessionRequestDto, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/yucca/auth/session", oazapfts.json({
...opts,
method: "POST",
body: createSessionRequestDto
})));
}
export function createTicket(ticketCreateRequestDto: TicketCreateRequestDto, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@@ -5,7 +5,6 @@ import { SocketEvent } from '$lib/events';
import {
createLocalBackend,
getBackends,
oidcDeviceFlow,
type BackendDto,
type CreateLocalBackendRequestDto,
type LocalRepositoryDto,
@@ -19,7 +18,6 @@ import { createMutation, createQuery } from '@tanstack/svelte-query';
export const backendKeys = {
all: ['backends'] as const,
deviceFlow: (uid: string) => ['deviceFlow', uid] as const,
};
export const useBackends = () =>
@@ -31,24 +29,6 @@ export const useBackends = () =>
() => queryClient,
);
export const useDeviceFlow = (uid: string) =>
createQuery(
() => ({
queryKey: backendKeys.deviceFlow(uid),
queryFn: async () => {
const response = await oidcDeviceFlow();
window.open(response.verificationUri, '_blank');
return response;
},
gcTime: Infinity,
retry: 0,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: true,
}),
() => queryClient,
);
export const useBackendEventHandler = () => {
return {
onBackendCreate(event: SocketEvent<{ backend: BackendDto }>) {
@@ -0,0 +1,104 @@
import type { DeviceFlowEventDto } from '$lib/fetch-client';
import {
startDeviceFlow,
type DeviceFlowKind,
} from '$lib/services/session.service';
const failures: Record<string, string> = {
NOT_CONNECTED:
'This instance is not connected to a FUTO Backups account yet.',
DEVICE_FLOW_FAILED: 'Login was cancelled or timed out.',
WRONG_ACCOUNT:
'That account does not own this instance. Log in with the account it was connected with.',
UNKNOWN: 'Could not reach FUTO Backups. Check your connection.',
};
export function createDeviceFlow(
kind: DeviceFlowKind,
handlers: {
createSession: (token: string) => Promise<unknown>;
onComplete: (event: DeviceFlowEventDto) => void;
onFailure?: (message: string) => void;
},
) {
const state = $state<{
userCode: string | undefined;
verificationUri: string | undefined;
error: string | undefined;
pending: boolean;
opened: boolean;
}>({
userCode: undefined,
verificationUri: undefined,
error: undefined,
pending: false,
opened: false,
});
let close: (() => void) | undefined;
const stop = () => {
close?.();
close = undefined;
};
const complete = async (event: DeviceFlowEventDto) => {
if (event.token) {
await handlers.createSession(event.token);
}
state.pending = false;
handlers.onComplete(event);
};
const start = () => {
stop();
state.userCode = undefined;
state.error = undefined;
state.pending = true;
state.opened = false;
close = startDeviceFlow(kind, (event) => {
switch (event.type) {
case 'START': {
state.userCode = event.userCode;
state.verificationUri = event.verificationUri;
break;
}
case 'SUCCESS': {
stop();
void complete(event);
break;
}
case 'FAILURE': {
stop();
state.pending = false;
state.userCode = undefined;
state.error = failures[event.reason ?? 'UNKNOWN'];
handlers.onFailure?.(state.error);
break;
}
}
});
};
const open = () => {
if (!state.verificationUri) {
return;
}
state.opened = true;
window.open(state.verificationUri, 'futo-backups-device');
};
return {
state,
start,
stop,
open,
};
}
export type DeviceFlow = ReturnType<typeof createDeviceFlow>;
@@ -0,0 +1,40 @@
import {
createSession,
defaults,
type DeviceFlowEventDto,
} from '$lib/fetch-client';
import { queryClient } from '$lib/query-client';
import { handleError } from '$lib/utils/handle-error';
import { createMutation } from '@tanstack/svelte-query';
export type DeviceFlowKind = 'oidc' | 'session';
export const startDeviceFlow = (
kind: DeviceFlowKind,
onEvent: (event: DeviceFlowEventDto) => void,
) => {
const source = new EventSource(
`${defaults.baseUrl.replace(/\/$/, '')}/api/yucca/auth/${kind}/device`,
{ withCredentials: true },
);
source.addEventListener('message', (event) => {
onEvent(JSON.parse(event.data) as DeviceFlowEventDto);
});
source.addEventListener('error', () => {
onEvent({ type: 'FAILURE', reason: 'UNKNOWN' });
source.close();
});
return () => source.close();
};
export const useCreateSession = () =>
createMutation(
() => ({
mutationFn: (token: string) => createSession({ token }),
onError: (error) => handleError(error, 'Failed to log in'),
}),
() => queryClient,
);
+3
View File
@@ -1283,6 +1283,9 @@ importers:
'@nestjs/event-emitter':
specifier: 'catalog:'
version: 3.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@nestjs/jwt':
specifier: 'catalog:'
version: 11.0.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
better-sqlite3:
specifier: 'catalog:'
version: 12.6.2