Skip to content

Commit b6525ec

Browse files
committed
feat(auth:sync): introduce registerWithAuth to enable desktop client registration from external process (OIDC)
1 parent 08c6e0f commit b6525ec

File tree

9 files changed

+133
-39
lines changed

9 files changed

+133
-39
lines changed

backend/src/applications/sync/constants/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const SYNC_ROUTE = {
1212
HANDSHAKE: 'handshake',
1313
REGISTER: 'register',
1414
UNREGISTER: 'unregister',
15+
REGISTER_AUTH: 'register/auth',
1516
APP_STORE: 'app-store',
1617
AUTH: 'auth',
1718
CLIENTS: 'clients',
@@ -23,3 +24,4 @@ export const SYNC_ROUTE = {
2324

2425
export const API_SYNC_AUTH_COOKIE = `${SYNC_ROUTE.BASE}/${SYNC_ROUTE.AUTH}/cookie`
2526
export const API_SYNC_CLIENTS = `${SYNC_ROUTE.BASE}/${SYNC_ROUTE.CLIENTS}`
27+
export const API_SYNC_REGISTER_AUTH = `${SYNC_ROUTE.BASE}/${SYNC_ROUTE.REGISTER_AUTH}`

backend/src/applications/sync/dtos/sync-client-auth.dto.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* See the LICENSE file for licensing details
55
*/
66

7-
import { IsDefined, IsNotEmpty, IsNotEmptyObject, IsObject, IsString, IsUUID } from 'class-validator'
8-
import { SyncClientInfo } from '../interfaces/sync-client.interface'
7+
import { IsBoolean, IsDefined, IsNotEmpty, IsNotEmptyObject, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'
8+
import type { SyncClientInfo } from '../interfaces/sync-client.interface'
99

1010
export class SyncClientAuthDto {
1111
@IsNotEmpty()
@@ -22,4 +22,8 @@ export class SyncClientAuthDto {
2222
@IsNotEmptyObject()
2323
@IsObject()
2424
info: SyncClientInfo
25+
26+
@IsOptional()
27+
@IsBoolean()
28+
tokenHasExpired?: boolean
2529
}

backend/src/applications/sync/dtos/sync-client-registration.dto.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,17 @@ export class SyncClientRegistrationDto {
2828
@IsDefined()
2929
@IsNotEmptyObject()
3030
@IsObject()
31-
info: SyncClientInfo
31+
info: SyncClientInfo // TODO: create a DTO for validation
32+
}
33+
34+
export class SyncClientAuthRegistrationDto {
35+
@IsOptional()
36+
@IsString()
37+
@IsUUID()
38+
clientId?: string
39+
40+
@IsDefined()
41+
@IsNotEmptyObject()
42+
@IsObject()
43+
info: SyncClientInfo // TODO: create a DTO for validation
3244
}

backend/src/applications/sync/interfaces/sync-client-auth.interface.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
* See the LICENSE file for licensing details
55
*/
66

7-
import { LoginResponseDto } from '../../../authentication/dto/login-response.dto'
8-
import { TokenResponseDto } from '../../../authentication/dto/token-response.dto'
7+
import type { LoginResponseDto } from '../../../authentication/dto/login-response.dto'
8+
import type { TokenResponseDto } from '../../../authentication/dto/token-response.dto'
99

10-
export class ClientAuthCookieDto extends LoginResponseDto {
11-
// send the new client token
12-
client_token_update?: string
13-
}
10+
// send the new client token
11+
export type SyncClientAuthCookie = LoginResponseDto & { client_token_update?: string }
12+
export type SyncClientAuthToken = TokenResponseDto & { client_token_update?: string }
1413

15-
export class ClientAuthTokenDto extends TokenResponseDto {
16-
// send the new client token
17-
client_token_update?: string
14+
export interface SyncClientAuthRegistration {
15+
clientId: string
16+
clientToken: string
1817
}

backend/src/applications/sync/interfaces/sync-client.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See the LICENSE file for licensing details
55
*/
66

7-
import { SYNC_CLIENT_TYPE } from '../constants/sync'
7+
import type { SYNC_CLIENT_TYPE } from '../constants/sync'
88

99
export interface SyncClientInfo {
1010
node: string

backend/src/applications/sync/services/sync-clients-manager.service.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import crypto from 'node:crypto'
1212
import fs from 'node:fs/promises'
1313
import path from 'node:path'
1414
import { AuthManager } from '../../../authentication/auth.service'
15+
import { FastifyAuthenticatedRequest } from '../../../authentication/interfaces/auth-request.interface'
1516
import { AuthProvider } from '../../../authentication/providers/auth-providers.models'
1617
import { AuthProvider2FA } from '../../../authentication/providers/two-fa/auth-provider-two-fa.service'
1718
import { convertHumanTimeToSeconds } from '../../../common/functions'
@@ -28,10 +29,11 @@ import { CLIENT_AUTH_TYPE, CLIENT_TOKEN_EXPIRATION_TIME, CLIENT_TOKEN_EXPIRED_ER
2829
import { APP_STORE_DIRNAME, APP_STORE_MANIFEST_FILE, APP_STORE_REPOSITORY, APP_STORE_URL } from '../constants/store'
2930
import { SYNC_CLIENT_TYPE } from '../constants/sync'
3031
import type { SyncClientAuthDto } from '../dtos/sync-client-auth.dto'
31-
import type { SyncClientRegistrationDto } from '../dtos/sync-client-registration.dto'
32+
import { SyncClientAuthRegistrationDto, SyncClientRegistrationDto } from '../dtos/sync-client-registration.dto'
3233
import { AppStoreManifest } from '../interfaces/store-manifest.interface'
33-
import { ClientAuthCookieDto, ClientAuthTokenDto } from '../interfaces/sync-client-auth.interface'
34+
import { SyncClientAuthCookie, SyncClientAuthRegistration, SyncClientAuthToken } from '../interfaces/sync-client-auth.interface'
3435
import { SyncClientPaths } from '../interfaces/sync-client-paths.interface'
36+
import { SyncClientInfo } from '../interfaces/sync-client.interface'
3537
import { SyncClient } from '../schemas/sync-client.interface'
3638
import { SyncQueries } from './sync-queries.service'
3739

@@ -48,7 +50,7 @@ export class SyncClientsManager {
4850
private readonly syncQueries: SyncQueries
4951
) {}
5052

51-
async register(clientRegistrationDto: SyncClientRegistrationDto, ip: string): Promise<{ clientToken: string }> {
53+
async register(clientRegistrationDto: SyncClientRegistrationDto, ip: string): Promise<SyncClientAuthRegistration> {
5254
const user: UserModel = await this.authMethod.validateUser(clientRegistrationDto.login, clientRegistrationDto.password)
5355
if (!user) {
5456
this.logger.warn(`${this.register.name} - auth failed for user *${clientRegistrationDto.login}*`)
@@ -75,14 +77,15 @@ export class SyncClientsManager {
7577
}
7678
}
7779
}
78-
try {
79-
const token = await this.syncQueries.getOrCreateClient(user.id, clientRegistrationDto.clientId, clientRegistrationDto.info, ip)
80-
this.logger.log(`${this.register.name} - client *${clientRegistrationDto.info.type}* was registered for user *${user.login}* (${user.id})`)
81-
return { clientToken: token }
82-
} catch (e) {
83-
this.logger.error(`${this.register.name} - ${e}`)
84-
throw new HttpException('Error during the client registration', HttpStatus.INTERNAL_SERVER_ERROR)
85-
}
80+
return this.getOrCreateClient(user, clientRegistrationDto.clientId, clientRegistrationDto.info, ip)
81+
}
82+
83+
async registerWithAuth(
84+
clientAuthenticatedRegistrationDto: SyncClientAuthRegistrationDto,
85+
req: FastifyAuthenticatedRequest
86+
): Promise<SyncClientAuthRegistration> {
87+
const clientId = clientAuthenticatedRegistrationDto.clientId || crypto.randomUUID()
88+
return this.getOrCreateClient(req.user, clientId, clientAuthenticatedRegistrationDto.info, req.ip)
8689
}
8790

8891
async unregister(user: UserModel): Promise<void> {
@@ -99,7 +102,7 @@ export class SyncClientsManager {
99102
syncClientAuthDto: SyncClientAuthDto,
100103
ip: string,
101104
res: FastifyReply
102-
): Promise<ClientAuthTokenDto | ClientAuthCookieDto> {
105+
): Promise<SyncClientAuthToken | SyncClientAuthCookie> {
103106
const client = await this.syncQueries.getClient(syncClientAuthDto.clientId, null, syncClientAuthDto.token)
104107
if (!client) {
105108
throw new HttpException('Client is unknown', HttpStatus.FORBIDDEN)
@@ -126,7 +129,7 @@ export class SyncClientsManager {
126129
user.clientId = client.id
127130
// update accesses
128131
this.usersManager.updateAccesses(user, ip, true).catch((e: Error) => this.logger.error(`${this.authenticate.name} - ${e}`))
129-
let r: ClientAuthTokenDto | ClientAuthCookieDto
132+
let r: SyncClientAuthToken | SyncClientAuthCookie
130133
if (authType === CLIENT_AUTH_TYPE.COOKIE) {
131134
// used by the desktop app to perform the login setup using cookies
132135
r = await this.authManager.setCookies(user, res)
@@ -209,4 +212,15 @@ export class SyncClientsManager {
209212
}
210213
return manifest
211214
}
215+
216+
private async getOrCreateClient(user: UserModel, clientId: string, clientInfo: SyncClientInfo, ip: string): Promise<SyncClientAuthRegistration> {
217+
try {
218+
const token = await this.syncQueries.getOrCreateClient(user.id, clientId, clientInfo, ip)
219+
this.logger.log(`${this.register.name} - client *${clientInfo.type}* was registered for user *${user.login}* (${user.id})`)
220+
return { clientId: clientId, clientToken: token } satisfies SyncClientAuthRegistration
221+
} catch (e) {
222+
this.logger.error(`${this.register.name} - ${e}`)
223+
throw new HttpException('Error during the client registration', HttpStatus.INTERNAL_SERVER_ERROR)
224+
}
225+
}
212226
}

backend/src/applications/sync/sync.controller.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@nestjs/common'
2929
import { FastifyReply, FastifyRequest } from 'fastify'
3030
import { AuthTokenSkip } from '../../authentication/decorators/auth-token-skip.decorator'
31+
import { FastifyAuthenticatedRequest } from '../../authentication/interfaces/auth-request.interface'
3132
import { ContextInterceptor } from '../../infrastructure/context/interceptors/context.interceptor'
3233
import { SkipSpacePermissionsCheck } from '../spaces/decorators/space-skip-permissions.decorator'
3334
import { FastifySpaceRequest } from '../spaces/interfaces/space-request.interface'
@@ -41,13 +42,13 @@ import { SYNC_ROUTE } from './constants/routes'
4142
import { CHECK_SERVER_RESP, SYNC_IN_SERVER_AGENT } from './constants/sync'
4243
import { SyncEnvironment } from './decorators/sync-environment.decorator'
4344
import { SyncClientAuthDto } from './dtos/sync-client-auth.dto'
44-
import type { SyncClientRegistrationDto } from './dtos/sync-client-registration.dto'
45+
import { SyncClientAuthRegistrationDto, SyncClientRegistrationDto } from './dtos/sync-client-registration.dto'
4546
import { SyncCopyMoveDto, SyncDiffDto, SyncMakeDto, SyncPropsDto } from './dtos/sync-operations.dto'
4647
import { SyncPathDto, SyncPathUpdateDto } from './dtos/sync-path.dto'
4748
import { SyncUploadDto } from './dtos/sync-upload.dto'
4849
import { SyncDiffGzipBodyInterceptor } from './interceptors/sync-diff-gzip-body.interceptor'
4950
import { AppStoreManifest } from './interfaces/store-manifest.interface'
50-
import { ClientAuthCookieDto, ClientAuthTokenDto } from './interfaces/sync-client-auth.interface'
51+
import { SyncClientAuthCookie, SyncClientAuthRegistration, SyncClientAuthToken } from './interfaces/sync-client-auth.interface'
5152
import { SyncClientPaths } from './interfaces/sync-client-paths.interface'
5253
import { SyncPathSettings } from './interfaces/sync-path.interface'
5354
import { SyncClientsManager } from './services/sync-clients-manager.service'
@@ -75,10 +76,20 @@ export class SyncController {
7576

7677
@Post(SYNC_ROUTE.REGISTER)
7778
@AuthTokenSkip()
78-
register(@Body() syncClientRegistrationDto: SyncClientRegistrationDto, @Req() req: FastifyRequest): Promise<{ clientToken: string }> {
79+
register(@Body() syncClientRegistrationDto: SyncClientRegistrationDto, @Req() req: FastifyRequest): Promise<SyncClientAuthRegistration> {
7980
return this.syncClientsManager.register(syncClientRegistrationDto, req.ip)
8081
}
8182

83+
@Post(SYNC_ROUTE.REGISTER_AUTH)
84+
@UserHavePermission(USER_PERMISSION.DESKTOP_APP)
85+
@UseGuards(UserPermissionsGuard)
86+
registerWithAuth(
87+
@Body() clientAuthenticatedRegistrationDto: SyncClientAuthRegistrationDto,
88+
@Req() req: FastifyAuthenticatedRequest
89+
): Promise<SyncClientAuthRegistration> {
90+
return this.syncClientsManager.registerWithAuth(clientAuthenticatedRegistrationDto, req)
91+
}
92+
8293
@Post(SYNC_ROUTE.UNREGISTER)
8394
@UserHavePermission(USER_PERMISSION.DESKTOP_APP)
8495
@UseGuards(UserPermissionsGuard)
@@ -89,6 +100,7 @@ export class SyncController {
89100
@Get(SYNC_ROUTE.APP_STORE)
90101
@AuthTokenSkip()
91102
checkAppStore(): Promise<AppStoreManifest> {
103+
// This route must be public to allow clients to receive updates
92104
return this.syncClientsManager.checkAppStore()
93105
}
94106

@@ -99,7 +111,7 @@ export class SyncController {
99111
@Body() clientAuthDto: SyncClientAuthDto,
100112
@Req() req: FastifyRequest,
101113
@Res({ passthrough: true }) res: FastifyReply
102-
): Promise<ClientAuthCookieDto | ClientAuthTokenDto> {
114+
): Promise<SyncClientAuthCookie | SyncClientAuthToken> {
103115
return this.syncClientsManager.authenticate(type, clientAuthDto, req.ip, res)
104116
}
105117

frontend/src/app/auth/auth.service.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import { HttpClient, HttpErrorResponse, HttpRequest } from '@angular/common/http
88
import { inject, Injectable } from '@angular/core'
99
import { Router } from '@angular/router'
1010
import { CLIENT_TOKEN_EXPIRED_ERROR } from '@sync-in-server/backend/src/applications/sync/constants/auth'
11-
import { API_SYNC_AUTH_COOKIE } from '@sync-in-server/backend/src/applications/sync/constants/routes'
11+
import { API_SYNC_AUTH_COOKIE, API_SYNC_REGISTER_AUTH } from '@sync-in-server/backend/src/applications/sync/constants/routes'
1212
import type { SyncClientAuthDto } from '@sync-in-server/backend/src/applications/sync/dtos/sync-client-auth.dto'
13-
import type { ClientAuthCookieDto } from '@sync-in-server/backend/src/applications/sync/interfaces/sync-client-auth.interface'
13+
import { SyncClientAuthCookie, SyncClientAuthRegistration } from '@sync-in-server/backend/src/applications/sync/interfaces/sync-client-auth.interface'
1414
import { API_ADMIN_IMPERSONATE_LOGOUT, API_USERS_ME } from '@sync-in-server/backend/src/applications/users/constants/routes'
1515
import { CSRF_KEY } from '@sync-in-server/backend/src/authentication/constants/auth'
1616
import {
@@ -142,10 +142,8 @@ export class AuthService {
142142
return true
143143
}),
144144
catchError((e: HttpErrorResponse) => {
145-
console.debug('token has expired')
146145
if (this.electron.enabled) {
147-
console.debug('login with app')
148-
return this.authenticateDesktopClient()
146+
return this.authDesktopClient()
149147
}
150148
this.logout(true, true)
151149
return throwError(() => e)
@@ -155,12 +153,17 @@ export class AuthService {
155153

156154
checkUserAuthAndLoad(returnUrl: string, authFromOIDC?: AuthOIDCQueryParams): Observable<boolean> {
157155
if (authFromOIDC) {
156+
// At this point, the cookies are stored in the session.
157+
console.debug(`${this.checkUserAuthAndLoad.name} - auth from OIDC`)
158158
this.accessExpiration = parseInt(authFromOIDC.access_expiration)
159159
this.refreshExpiration = parseInt(authFromOIDC.refresh_expiration)
160+
if (this.electron.enabled) {
161+
return this.authOIDCDesktopClient()
162+
}
160163
}
161164
if (this.refreshTokenHasExpired()) {
162165
if (this.electron.enabled) {
163-
return this.authenticateDesktopClient()
166+
return this.authDesktopClient()
164167
}
165168
this.returnUrl = returnUrl.length > 1 ? returnUrl : null
166169
this.logout()
@@ -236,16 +239,17 @@ export class AuthService {
236239
})
237240
}
238241

239-
private authenticateDesktopClient(): Observable<boolean> {
242+
private authDesktopClient(): Observable<boolean> {
240243
return this.electron.authenticate().pipe(
241244
switchMap((auth: SyncClientAuthDto) => {
242245
if (!auth.clientId) {
243246
// No auth was provided, the Sync-in desktop app must be registered
247+
console.debug(`${this.authDesktopClient.name} - client must be registered`)
244248
this.logout(true)
245249
return of(false)
246250
}
247-
return this.http.post<ClientAuthCookieDto>(API_SYNC_AUTH_COOKIE, auth).pipe(
248-
map((r: ClientAuthCookieDto) => {
251+
return this.http.post<SyncClientAuthCookie>(API_SYNC_AUTH_COOKIE, auth).pipe(
252+
map((r: SyncClientAuthCookie) => {
249253
this.accessExpiration = r.token.access_expiration
250254
this.refreshExpiration = r.token.refresh_expiration
251255
this.initUser(r)
@@ -256,6 +260,7 @@ export class AuthService {
256260
return true
257261
}),
258262
catchError((e: HttpErrorResponse) => {
263+
console.debug(`${this.authDesktopClient.name} - ${e.error.message}`)
259264
if (e.error.message === CLIENT_TOKEN_EXPIRED_ERROR) {
260265
this.electron.send(EVENT.SERVER.AUTHENTICATION_TOKEN_EXPIRED)
261266
} else {
@@ -270,6 +275,39 @@ export class AuthService {
270275
)
271276
}
272277

278+
private authOIDCDesktopClient(): Observable<boolean> {
279+
return this.electron.authenticate().pipe(
280+
switchMap((auth: SyncClientAuthDto) => {
281+
if (!auth.clientId || auth.tokenHasExpired) {
282+
// The client must be registered, or the token must be renewed
283+
return this.http.post<SyncClientAuthRegistration>(API_SYNC_REGISTER_AUTH, auth).pipe(
284+
switchMap((externalAuth: SyncClientAuthRegistration) => {
285+
return this.electron.externalRegister(externalAuth).pipe(
286+
switchMap((success: boolean) => {
287+
if (success) {
288+
console.debug(`${this.authOIDCDesktopClient.name} - ${auth.clientId ? 'client was registered' : 'client token renewed'}`)
289+
return this.authDesktopClient()
290+
} else {
291+
this.logout(true, true)
292+
return of(false)
293+
}
294+
})
295+
)
296+
}),
297+
catchError((e: HttpErrorResponse) => {
298+
console.error(`${this.authOIDCDesktopClient.name} - ${e}`)
299+
this.logout(true)
300+
return of(false)
301+
})
302+
)
303+
} else {
304+
// The client must be authenticated
305+
return this.authDesktopClient()
306+
}
307+
})
308+
)
309+
}
310+
273311
private refreshTokenHasExpired(): boolean {
274312
return this.refreshExpiration === 0 || currentTimeStamp() >= this.refreshExpiration
275313
}

frontend/src/app/electron/electron.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { effect, inject, Injectable, NgZone } from '@angular/core'
88
import { toObservable } from '@angular/core/rxjs-interop'
99
import { FileTask } from '@sync-in-server/backend/src/applications/files/models/file-task'
1010
import type { SyncClientAuthDto } from '@sync-in-server/backend/src/applications/sync/dtos/sync-client-auth.dto'
11+
import type { SyncClientAuthRegistration } from '@sync-in-server/backend/src/applications/sync/interfaces/sync-client-auth.interface'
1112
import { combineLatest, from, map, Observable } from 'rxjs'
1213
import { NotificationModel } from '../applications/notifications/models/notification.model'
1314
import { CLIENT_APP_COUNTER, CLIENT_SCHEDULER_STATE } from '../applications/sync/constants/client'
@@ -71,15 +72,27 @@ export class Electron {
7172
}
7273

7374
authenticate(): Observable<SyncClientAuthDto> {
75+
// Get information about client authentication
7476
return from(this.invoke(EVENT.SERVER.AUTHENTICATION))
7577
}
7678

7779
register(login: string, password: string, code?: string): Observable<AuthResult> {
80+
// The client handles the registration.
7881
return from(this.invoke(EVENT.SERVER.REGISTRATION, { login, password, code })).pipe(
7982
map((e: { ok: boolean; msg?: string }) => ({ success: e.ok, message: e.msg ?? null }) satisfies AuthResult)
8083
)
8184
}
8285

86+
externalRegister(externalAuth: SyncClientAuthRegistration): Observable<boolean> {
87+
// The registration has already been completed on the server, and the client must be updated accordingly.
88+
return from(this.invoke(EVENT.SERVER.REGISTRATION, null, externalAuth)).pipe(
89+
map((e: { ok: boolean; msg?: string }) => {
90+
if (!e.ok) console.error(`${this.externalRegister.name} - ${e.msg}`)
91+
return e.ok
92+
})
93+
)
94+
}
95+
8396
openPath(path: string) {
8497
this.send(EVENT.MISC.FILE_OPEN, path)
8598
}

0 commit comments

Comments
 (0)