luminate/Sources/LuminateServices/Auth/JellyfinClient+Auth.swift

273 lines
12 KiB
Swift

//
// JellyfinClient+Auth.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// 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) }
)
}
}