From 312062e33c6026db633c2c39a004ff08828fc0e7 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Thu, 3 Sep 2026 08:35:35 -0400 Subject: [PATCH] feat: standalone app login & work around Zitadel race condition (#556) --- .mise/tasks/standalone-app/dev | 2 +- USER_MANUAL.md | 4 +- packages/e2e/test/orchestration-api.spec.ts | 35 ++- packages/standalone-app/src/server/env.ts | 3 + packages/standalone-app/src/server/main.ts | 1 + packages/yucca-api-client/openapi-specs.json | 80 ++++++- packages/yucca-api-client/src/fetch-client.ts | 23 +- .../src/controllers/auth.controller.ts | 9 +- packages/yucca-api/src/dto/auth.dto.ts | 21 ++ packages/yucca-api/src/enum.ts | 12 + .../src/services/auth.service.spec.ts | 22 +- .../yucca-api/src/services/auth.service.ts | 73 ++++-- .../yucca-api/test/auth.integration-spec.ts | 3 +- .../orchestration-api/openapi-specs.json | 110 +++++++++- .../yucca-sdk/orchestration-api/package.json | 1 + .../yucca-sdk/orchestration-api/src/const.ts | 1 + .../src/controllers/auth.controller.ts | 50 ++++- .../src/controllers/onboarding.controller.ts | 8 +- .../orchestration-api/src/dto/auth.dto.ts | 28 ++- .../src/dto/onboarding.dto.ts | 6 + .../yucca-sdk/orchestration-api/src/enum.ts | 19 ++ .../src/events/events.gateway.ts | 19 +- .../src/middleware/session.guard.ts | 40 ++++ .../orchestration-api/src/moduleConfig.ts | 1 + .../src/orchestrationApi.module.ts | 8 +- .../src/repositories/config.repository.ts | 14 ++ .../src/schema/tables/backend.table.ts | 1 + .../src/services/auth.service.ts | 207 ++++++++++++------ .../src/services/onboarding.service.ts | 11 +- .../src/services/session.service.spec.ts | 60 +++++ .../src/services/session.service.ts | 91 ++++++++ .../orchestration-api/test/testUtils.ts | 2 + .../dialogs/OAuthDeviceFlowModal.svelte | 87 +++----- .../onboarding/OnboardingGate.svelte | 7 + .../onboarding/OnboardingLogin.svelte | 64 ++++++ .../components/util/DeviceFlowAction.svelte | 21 ++ .../lib/components/util/DeviceFlowCode.svelte | 36 +++ .../orchestration-ui/src/lib/fetch-client.ts | 36 ++- .../src/lib/services/backend.service.ts | 20 -- .../lib/services/deviceFlow.service.svelte.ts | 104 +++++++++ .../src/lib/services/session.service.ts | 40 ++++ pnpm-lock.yaml | 3 + 42 files changed, 1178 insertions(+), 205 deletions(-) create mode 100644 packages/yucca-sdk/orchestration-api/src/middleware/session.guard.ts create mode 100644 packages/yucca-sdk/orchestration-api/src/services/session.service.spec.ts create mode 100644 packages/yucca-sdk/orchestration-api/src/services/session.service.ts create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingLogin.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowAction.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowCode.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/services/deviceFlow.service.svelte.ts create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/services/session.service.ts diff --git a/.mise/tasks/standalone-app/dev b/.mise/tasks/standalone-app/dev index 55d74c81..7026ea17 100755 --- a/.mise/tasks/standalone-app/dev +++ b/.mise/tasks/standalone-app/dev @@ -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} diff --git a/USER_MANUAL.md b/USER_MANUAL.md index ec43e68d..1346d769 100644 --- a/USER_MANUAL.md +++ b/USER_MANUAL.md @@ -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: diff --git a/packages/e2e/test/orchestration-api.spec.ts b/packages/e2e/test/orchestration-api.spec.ts index 42c25c06..e571b058 100644 --- a/packages/e2e/test/orchestration-api.spec.ts +++ b/packages/e2e/test/orchestration-api.spec.ts @@ -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, diff --git a/packages/standalone-app/src/server/env.ts b/packages/standalone-app/src/server/env.ts index b14844f3..a4206afa 100644 --- a/packages/standalone-app/src/server/env.ts +++ b/packages/standalone-app/src/server/env.ts @@ -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, }; }; diff --git a/packages/standalone-app/src/server/main.ts b/packages/standalone-app/src/server/main.ts index 1827a925..2a3c2698 100644 --- a/packages/standalone-app/src/server/main.ts +++ b/packages/standalone-app/src/server/main.ts @@ -21,6 +21,7 @@ async function bootstrap() { useFactory: () => ({ statePath: env.statePath, wellKnownUrl: env.wellKnownUrl, + requireSession: !env.disableAuth, }), }), ); diff --git a/packages/yucca-api-client/openapi-specs.json b/packages/yucca-api-client/openapi-specs.json index 6e86ed5c..7d956879 100644 --- a/packages/yucca-api-client/openapi-specs.json +++ b/packages/yucca-api-client/openapi-specs.json @@ -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": [ diff --git a/packages/yucca-api-client/src/fetch-client.ts b/packages/yucca-api-client/src/fetch-client.ts index 81d3736e..8369b6f3 100644 --- a/packages/yucca-api-client/src/fetch-client.ts +++ b/packages/yucca-api-client/src/fetch-client.ts @@ -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 }))}`, { diff --git a/packages/yucca-api/src/controllers/auth.controller.ts b/packages/yucca-api/src/controllers/auth.controller.ts index 3c0d24e7..446b6f26 100644 --- a/packages/yucca-api/src/controllers/auth.controller.ts +++ b/packages/yucca-api/src/controllers/auth.controller.ts @@ -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 { + 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( diff --git a/packages/yucca-api/src/dto/auth.dto.ts b/packages/yucca-api/src/dto/auth.dto.ts index a984cb27..cf11b797 100644 --- a/packages/yucca-api/src/dto/auth.dto.ts +++ b/packages/yucca-api/src/dto/auth.dto.ts @@ -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; } + +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; +} diff --git a/packages/yucca-api/src/enum.ts b/packages/yucca-api/src/enum.ts index 4be55533..2fbb7b99 100644 --- a/packages/yucca-api/src/enum.ts +++ b/packages/yucca-api/src/enum.ts @@ -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, } diff --git a/packages/yucca-api/src/services/auth.service.spec.ts b/packages/yucca-api/src/services/auth.service.spec.ts index 9a5323f2..c4459fa5 100644 --- a/packages/yucca-api/src/services/auth.service.spec.ts +++ b/packages/yucca-api/src/services/auth.service.spec.ts @@ -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' })); diff --git a/packages/yucca-api/src/services/auth.service.ts b/packages/yucca-api/src/services/auth.service.ts index 29dbd670..384662ab 100644 --- a/packages/yucca-api/src/services/auth.service.ts +++ b/packages/yucca-api/src/services/auth.service.ts @@ -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( + (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( @@ -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); + } } diff --git a/packages/yucca-api/test/auth.integration-spec.ts b/packages/yucca-api/test/auth.integration-spec.ts index 479b5f20..e16252c6 100644 --- a/packages/yucca-api/test/auth.integration-spec.ts +++ b/packages/yucca-api/test/auth.integration-spec.ts @@ -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(); diff --git a/packages/yucca-sdk/orchestration-api/openapi-specs.json b/packages/yucca-sdk/orchestration-api/openapi-specs.json index 34eb3ff2..ae6f756f 100644 --- a/packages/yucca-sdk/orchestration-api/openapi-specs.json +++ b/packages/yucca-sdk/orchestration-api/openapi-specs.json @@ -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", diff --git a/packages/yucca-sdk/orchestration-api/package.json b/packages/yucca-sdk/orchestration-api/package.json index 91a70c24..ca5f3067 100644 --- a/packages/yucca-sdk/orchestration-api/package.json +++ b/packages/yucca-sdk/orchestration-api/package.json @@ -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:", diff --git a/packages/yucca-sdk/orchestration-api/src/const.ts b/packages/yucca-sdk/orchestration-api/src/const.ts index 834279aa..ba8f1210 100644 --- a/packages/yucca-sdk/orchestration-api/src/const.ts +++ b/packages/yucca-sdk/orchestration-api/src/const.ts @@ -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; diff --git a/packages/yucca-sdk/orchestration-api/src/controllers/auth.controller.ts b/packages/yucca-sdk/orchestration-api/src/controllers/auth.controller.ts index af7478c9..c8dfe67f 100644 --- a/packages/yucca-sdk/orchestration-api/src/controllers/auth.controller.ts +++ b/packages/yucca-sdk/orchestration-api/src/controllers/auth.controller.ts @@ -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 { - 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') diff --git a/packages/yucca-sdk/orchestration-api/src/controllers/onboarding.controller.ts b/packages/yucca-sdk/orchestration-api/src/controllers/onboarding.controller.ts index fde7fe0e..8cc08d50 100644 --- a/packages/yucca-sdk/orchestration-api/src/controllers/onboarding.controller.ts +++ b/packages/yucca-sdk/orchestration-api/src/controllers/onboarding.controller.ts @@ -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 { - return this.service.onboardingStatus(); + async onboardingStatus(@Req() request: SessionRequest): Promise { + return this.service.onboardingStatus(request.session); } @Get('/recovery-key') diff --git a/packages/yucca-sdk/orchestration-api/src/dto/auth.dto.ts b/packages/yucca-sdk/orchestration-api/src/dto/auth.dto.ts index dc22110e..0725b95a 100644 --- a/packages/yucca-sdk/orchestration-api/src/dto/auth.dto.ts +++ b/packages/yucca-sdk/orchestration-api/src/dto/auth.dto.ts @@ -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; } diff --git a/packages/yucca-sdk/orchestration-api/src/dto/onboarding.dto.ts b/packages/yucca-sdk/orchestration-api/src/dto/onboarding.dto.ts index 4bd61a07..47f5dd78 100644 --- a/packages/yucca-sdk/orchestration-api/src/dto/onboarding.dto.ts +++ b/packages/yucca-sdk/orchestration-api/src/dto/onboarding.dto.ts @@ -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; diff --git a/packages/yucca-sdk/orchestration-api/src/enum.ts b/packages/yucca-sdk/orchestration-api/src/enum.ts index e84e0fa0..0c40f4f7 100644 --- a/packages/yucca-sdk/orchestration-api/src/enum.ts +++ b/packages/yucca-sdk/orchestration-api/src/enum.ts @@ -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', diff --git a/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts b/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts index 4a526a4c..432215cb 100644 --- a/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts +++ b/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts @@ -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; - } - | { - 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, diff --git a/packages/yucca-sdk/orchestration-api/src/middleware/session.guard.ts b/packages/yucca-sdk/orchestration-api/src/middleware/session.guard.ts new file mode 100644 index 00000000..0888b479 --- /dev/null +++ b/packages/yucca-sdk/orchestration-api/src/middleware/session.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + const configuration = await this.session.cloudConfiguration(); + request.session = await this.session.fromCookieHeader(request.headers.cookie, configuration); + + const isPublic = this.reflector.getAllAndOverride(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; + } +} diff --git a/packages/yucca-sdk/orchestration-api/src/moduleConfig.ts b/packages/yucca-sdk/orchestration-api/src/moduleConfig.ts index fa12d1d6..62148022 100644 --- a/packages/yucca-sdk/orchestration-api/src/moduleConfig.ts +++ b/packages/yucca-sdk/orchestration-api/src/moduleConfig.ts @@ -30,6 +30,7 @@ export type ModuleConfig = { wellKnownUrl?: string; externalBaseUrl?: string; requireWsAuth?: boolean; + requireSession?: boolean; requireLock?: boolean; developmentMode?: boolean; diff --git a/packages/yucca-sdk/orchestration-api/src/orchestrationApi.module.ts b/packages/yucca-sdk/orchestration-api/src/orchestrationApi.module.ts index 6562e79e..2c229351 100644 --- a/packages/yucca-sdk/orchestration-api/src/orchestrationApi.module.ts +++ b/packages/yucca-sdk/orchestration-api/src/orchestrationApi.module.ts @@ -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, diff --git a/packages/yucca-sdk/orchestration-api/src/repositories/config.repository.ts b/packages/yucca-sdk/orchestration-api/src/repositories/config.repository.ts index 7aec8f78..71c3c8e2 100644 --- a/packages/yucca-sdk/orchestration-api/src/repositories/config.repository.ts +++ b/packages/yucca-sdk/orchestration-api/src/repositories/config.repository.ts @@ -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 { + return Buffer.from(await this.get(ConfigurationKey.SessionSecret), 'hex'); + } + async getResticOptions( placement: ResticPlacement, ): Promise<{ connections: number; packSizeMib: number | undefined }> { diff --git a/packages/yucca-sdk/orchestration-api/src/schema/tables/backend.table.ts b/packages/yucca-sdk/orchestration-api/src/schema/tables/backend.table.ts index 96ba50d8..c5762b39 100644 --- a/packages/yucca-sdk/orchestration-api/src/schema/tables/backend.table.ts +++ b/packages/yucca-sdk/orchestration-api/src/schema/tables/backend.table.ts @@ -12,6 +12,7 @@ export type BackendConfiguration = type: BackendType.Yucca; url?: string; accessToken: string; + userId?: string; } | { /** diff --git a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts index 1c4b7d44..5a1162df 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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( + (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 { diff --git a/packages/yucca-sdk/orchestration-api/src/services/onboarding.service.ts b/packages/yucca-sdk/orchestration-api/src/services/onboarding.service.ts index d88c8524..849a3b63 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/onboarding.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/onboarding.service.ts @@ -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 { + async onboardingStatus(session?: Session): Promise { 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, diff --git a/packages/yucca-sdk/orchestration-api/src/services/session.service.spec.ts b/packages/yucca-sdk/orchestration-api/src/services/session.service.spec.ts new file mode 100644 index 00000000..fca723f0 --- /dev/null +++ b/packages/yucca-sdk/orchestration-api/src/services/session.service.spec.ts @@ -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); + }); +}); diff --git a/packages/yucca-sdk/orchestration-api/src/services/session.service.ts b/packages/yucca-sdk/orchestration-api/src/services/session.service.ts new file mode 100644 index 00000000..16672775 --- /dev/null +++ b/packages/yucca-sdk/orchestration-api/src/services/session.service.ts @@ -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 { + 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 { + return this.jwt.signAsync( + {}, + { + secret: await this.signingKey(), + subject: userId, + expiresIn: SESSION_TTL_MS / 1000, + }, + ); + } + + async verify(token: string | undefined, configuration: CloudConfiguration | undefined): Promise { + 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 { + 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 { + if (!header) { + return; + } + + return this.verify(parseCookies(header)[CookieName.SessionToken], configuration); + } + + private async signingKey(): Promise { + return this.config.getSessionSecret(); + } +} diff --git a/packages/yucca-sdk/orchestration-api/test/testUtils.ts b/packages/yucca-sdk/orchestration-api/test/testUtils.ts index 78d3edf0..2dc3f953 100644 --- a/packages/yucca-sdk/orchestration-api/test/testUtils.ts +++ b/packages/yucca-sdk/orchestration-api/test/testUtils.ts @@ -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 { }, ]), EventEmitterModule.forRoot(), + JwtModule.register({}), ScheduleModule.forRoot(), ], controllers, diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/dialogs/OAuthDeviceFlowModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/dialogs/OAuthDeviceFlowModal.svelte index 2081c105..ad51936b 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/dialogs/OAuthDeviceFlowModal.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/dialogs/OAuthDeviceFlowModal.svelte @@ -1,26 +1,21 @@ - + - - - You may be asked or shown the following code: - - {query.data!.userCode} - - - - - - Waiting for you to confirm login... - - - + {#if flow.state.userCode} + + {:else if flow.state.error} + {flow.state.error} + {:else} + + {/if} - + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte index 879a1659..43bd1500 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte @@ -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 @@ {:else if onboarding.status === "error"} +{:else if onboarding.requiresAuthentication && !onboarding.isAuthenticated} + (onboarding!.isAuthenticated = true)} + /> {:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey)} + 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); + + + {}}> + + {#if flow.state.userCode} + + {:else} + + + This instance is connected to a FUTO Backups account. Log in with that + account to manage it. + + + {#if flow.state.error} + {flow.state.error} + {/if} + + {/if} + + + + {#if flow.state.userCode} + + {:else} + + {/if} + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowAction.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowAction.svelte new file mode 100644 index 00000000..f57e3712 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowAction.svelte @@ -0,0 +1,21 @@ + + +{#if flow.state.opened} + +{:else} + +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowCode.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowCode.svelte new file mode 100644 index 00000000..43e5304e --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/DeviceFlowCode.svelte @@ -0,0 +1,36 @@ + + + + You may be asked or shown the following code: + + {flow.state.userCode} + + + + {#if flow.state.opened} + + + Waiting for you to confirm login... + + {/if} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts b/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts index 9843b01e..48b4adca 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts @@ -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; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts index 06e879ef..08d16fb5 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts @@ -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 }>) { diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/deviceFlow.service.svelte.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/deviceFlow.service.svelte.ts new file mode 100644 index 00000000..bf87b43a --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/deviceFlow.service.svelte.ts @@ -0,0 +1,104 @@ +import type { DeviceFlowEventDto } from '$lib/fetch-client'; +import { + startDeviceFlow, + type DeviceFlowKind, +} from '$lib/services/session.service'; + +const failures: Record = { + 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; + 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; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/session.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/session.service.ts new file mode 100644 index 00000000..a0cd4029 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/session.service.ts @@ -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, + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aef47140..05e211cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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