mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat: standalone app login & work around Zitadel race condition (#556)
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
+31
-56
@@ -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>
|
||||
|
||||
+7
@@ -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}
|
||||
|
||||
+64
@@ -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,
|
||||
);
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user