// // JellyfinClient+Auth.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import LuminateAPI import LuminateCore /// Sign-in, Quick Connect pairing, and password management. extension JellyfinClient { /// Fetches the accounts the server advertises on its login screen. /// /// A server configured to hide its user list answers with an empty array rather than failing, /// so an empty result means "type your username", not "no accounts exist". /// /// - Returns: The publicly visible accounts. /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is /// starting, or a transport error if the host is unreachable. package func publicUsers() async throws -> [JellyfinUser] { switch try await current.getPublicUsers(.init()) { case .ok(let response): return response.body.payload.map(JellyfinUser.init) case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.GetPublicUsers.id, statusCode: statusCode ) } } /// Signs in with a username and password and stores the resulting token on this client. /// /// Every later call through this client is authenticated as the returned account, so a caller /// does not have to plumb the token back in. /// /// Jellyfin answers bad credentials with an undocumented 401, which surfaces as /// ``JellyfinClientError/unexpectedStatus(operation:statusCode:)`` rather than /// ``JellyfinClientError/unauthorized``: the specification declares no 401 for this operation. /// /// - Parameters: /// - username: The account name. Must not be empty. /// - password: The account password. May be empty; Jellyfin permits passwordless accounts. /// - Returns: The issued token and the account it belongs to. /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `username` is empty, or /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { guard !username.isEmpty else { throw JellyfinClientError.invalidArgument(name: "username") } let input = Operations.AuthenticateUserByName.Input( body: .json(.init(value1: Components.Schemas.AuthenticateUserByName(username: username, pw: password))) ) switch try await current.authenticateUserByName(input) { case .ok(let response): return try await store(response.body.payload, from: Operations.AuthenticateUserByName.id) case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.AuthenticateUserByName.id, statusCode: statusCode ) } } /// Reports whether the server allows Quick Connect pairing. /// /// - Returns: `true` when Quick Connect is enabled. /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is /// starting, or a transport error if the host is unreachable. package func quickConnectEnabled() async throws -> Bool { switch try await current.getQuickConnectEnabled(.init()) { case .ok(let response): return response.body.payload case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.GetQuickConnectEnabled.id, statusCode: statusCode ) } } /// Starts a Quick Connect pairing attempt. /// /// Show the returned ``JellyfinQuickConnectState/code`` to the user, keep the /// ``JellyfinQuickConnectState/secret`` private, and poll ``quickConnectState(secret:)`` until /// it reports the request authenticated. /// /// - Returns: The new pairing request. /// - Throws: ``JellyfinClientError/unauthorized`` if the server has Quick Connect disabled. package func initiateQuickConnect() async throws -> JellyfinQuickConnectState { switch try await current.initiateQuickConnect(.init()) { case .ok(let response): return JellyfinQuickConnectState(response.body.payload) case .unauthorized: throw JellyfinClientError.unauthorized case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.InitiateQuickConnect.id, statusCode: statusCode ) } } /// Polls an in-flight Quick Connect request. /// /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. /// - Returns: The current state of the request. /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, or /// ``JellyfinClientError/notFound`` once the server has expired the request. package func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState { guard !secret.isEmpty else { throw JellyfinClientError.invalidArgument(name: "secret") } switch try await current.getQuickConnectState(.init(query: .init(secret: secret))) { case .ok(let response): return JellyfinQuickConnectState(response.body.payload) case .notFound: throw JellyfinClientError.notFound case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.GetQuickConnectState.id, statusCode: statusCode ) } } /// Redeems an approved Quick Connect secret and stores the resulting token on this client. /// /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. /// - Returns: The issued token and the account it belongs to. /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, /// ``JellyfinClientError/badRequest`` if the request was never approved, or /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. package func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication { guard !secret.isEmpty else { throw JellyfinClientError.invalidArgument(name: "secret") } let input = Operations.AuthenticateWithQuickConnect.Input( body: .json(.init(value1: Components.Schemas.QuickConnectDto(secret: secret))) ) switch try await current.authenticateWithQuickConnect(input) { case .ok(let response): return try await store(response.body.payload, from: Operations.AuthenticateWithQuickConnect.id) case .badRequest: throw JellyfinClientError.badRequest case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.AuthenticateWithQuickConnect.id, statusCode: statusCode ) } } /// Changes or resets an account password. /// /// Also serves the server-mandated forced password change: pass the current password alongside /// the new one. /// /// - Parameters: /// - userID: The account to change; `nil` means the signed-in account. /// - currentPassword: The existing password, required unless resetting. /// - currentPIN: The existing numeric Easy PIN, when the account uses one. /// - newPassword: The replacement password. /// - resetPassword: Pass `true` to clear the password instead of setting a new one. /// - Throws: ``JellyfinClientError/unauthorized`` if the current password is wrong, /// ``JellyfinClientError/forbidden`` if the account may not change its own password, or /// ``JellyfinClientError/notFound`` if no such account exists. package func updateUserPassword( userID: String?, currentPassword: String?, currentPIN: String?, newPassword: String?, resetPassword: Bool? ) async throws { let input = Operations.UpdateUserPassword.Input( query: .init(userId: userID), body: .json( .init( value1: Components.Schemas.UpdateUserPassword( currentPassword: currentPassword, currentPw: currentPIN, newPw: newPassword, resetPassword: resetPassword ) ) ) ) switch try await current.updateUserPassword(input) { case .noContent: return case .forbidden: throw JellyfinClientError.forbidden case .notFound: throw JellyfinClientError.notFound case .serviceUnavailable(let response): throw JellyfinClientError.serviceUnavailable( retryAfterSeconds: response.headers.retryAfter ) case .unauthorized: throw JellyfinClientError.unauthorized case .undocumented(let statusCode, _): throw JellyfinClientError.unexpectedStatus( operation: Operations.UpdateUserPassword.id, statusCode: statusCode ) } } /// Maps an authentication result and adopts its token as this client's credentials. /// /// Shared by password and Quick Connect sign-in, which differ only in how they obtain the /// result. A result with no token is treated as a failure: the server said yes but gave the /// client nothing to authenticate with. /// /// - Parameters: /// - result: The generated authentication result. /// - operation: The operation identifier, used in the thrown error. /// - Returns: The mapped authentication. /// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the token is absent or empty. private func store( _ result: Components.Schemas.AuthenticationResult, from operation: String ) async throws -> JellyfinAuthentication { guard let token = result.accessToken, !token.isEmpty else { throw JellyfinClientError.missingPayload(operation: operation) } setAccessToken(token) return JellyfinAuthentication( accessToken: token, serverID: result.serverId, user: result.user.map { JellyfinUser($0.value1) } ) } }