Kein Code mehr zum Abtippen. Die Anmeldemaske kommt von Plex und öffnet sich über ASWebAuthenticationSession in einem Fenster innerhalb der App; nach dem Anmelden schickt Plex über forwardUrl auf ein eigenes Schema zurück, das Fenster schließt sich von selbst. Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem Plex einen Token herausgibt. Sichtbar ist er nicht mehr. Was das bringt: - Kein Fernseher-Gefühl mehr. - Zwei-Faktor läuft mit, weil Plex die Seite selbst ausliefert. - Das Passwort erreicht Flixy nie. Steht auch so unter dem Knopf. - prefersEphemeralWebBrowserSession = false: wer in Safari schon bei Plex angemeldet ist, braucht nur zu bestätigen. Zwei Details, die sonst still scheitern: - Die Rückadresse wird strenger kodiert als die übrigen Werte. `:` und `/` lässt urlQueryAllowed durch, und für Plex endete der Wert dann beim ersten Schrägstrich — der Rücksprung käme nie. - Das Schema muss in CFBundleURLTypes stehen, sonst kennt iOS es nicht und das Fenster bliebe offen stehen. Als Schema die Bundle-Kennung statt eines kurzen Worts wie „flixy": ein kurzes könnte eine andere App beanspruchen und die Anmeldung abfangen. 132 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
4.1 KiB
Swift
97 lines
4.1 KiB
Swift
import Foundation
|
|
|
|
/// Der Anmeldecode aus dem PIN-Verfahren.
|
|
///
|
|
/// Plex legt auf Anfrage einen vierstelligen Code an. Der Nutzer bestätigt ihn
|
|
/// im Browser bei plex.tv, danach trägt derselbe Datensatz den Token. Die App
|
|
/// selbst bekommt nie Benutzername oder Passwort zu sehen — deshalb ist dieser
|
|
/// Weg dem manuellen Eintippen eines Tokens vorzuziehen.
|
|
public struct PlexPin: Sendable, Equatable, Decodable {
|
|
public let id: Int
|
|
public let code: String
|
|
public let authToken: String?
|
|
public let expiresAt: Date?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, code, authToken, expiresAt
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.id = try values.decode(Int.self, forKey: .id)
|
|
self.code = try values.decode(String.self, forKey: .code)
|
|
self.authToken = try values.decodeIfPresent(String.self, forKey: .authToken)
|
|
|
|
// Plex liefert ISO-8601 mit Zeitzone. Ein eigener Formatierer statt
|
|
// einer Decoder-Strategie, weil sonst alle anderen Datumsfelder in
|
|
// dieser Antwort mitbetroffen wären.
|
|
if let text = try values.decodeIfPresent(String.self, forKey: .expiresAt) {
|
|
self.expiresAt = ISO8601DateFormatter().date(from: text)
|
|
} else {
|
|
self.expiresAt = nil
|
|
}
|
|
}
|
|
|
|
public init(id: Int, code: String, authToken: String? = nil, expiresAt: Date? = nil) {
|
|
self.id = id
|
|
self.code = code
|
|
self.authToken = authToken
|
|
self.expiresAt = expiresAt
|
|
}
|
|
|
|
/// Die Adresse, unter der der Nutzer den Code bestätigt.
|
|
///
|
|
/// Von Hand zusammengesetzt statt über `URLComponents`. Der Grund ist die
|
|
/// Form, die Plex verlangt: die Parameter stehen im Fragment (hinter dem
|
|
/// `#`), nicht in der Abfrage, und einer davon heißt buchstäblich
|
|
/// `context[device][product]`. Die eckigen Klammern müssen genau einmal
|
|
/// kodiert sein.
|
|
///
|
|
/// `URLComponents.fragment` kodiert beim Setzen ein zweites Mal: aus `%5B`
|
|
/// wird `%255B`. Plex sieht dann einen Parameter, den es nicht kennt, und
|
|
/// antwortet mit „Diese Anfrage konnte nicht abgeschlossen werden".
|
|
/// Festgehalten in `AuthorizationURLTests`.
|
|
///
|
|
/// `forwardUrl` schickt den Browser nach der Anmeldung auf eine Adresse
|
|
/// mit eigenem Schema zurück. Genau darauf wartet
|
|
/// `ASWebAuthenticationSession`: das Anmeldefenster schließt sich von
|
|
/// selbst, und die App weiß, dass sie den Token abholen kann. Ohne diese
|
|
/// Rückadresse bliebe die Seite offen und der Nutzer müsste raten, wann er
|
|
/// fertig ist.
|
|
public func authorizationURL(
|
|
clientIdentifier: String,
|
|
product: String,
|
|
forwardURL: URL? = nil
|
|
) -> URL? {
|
|
// `&`, `=` und `+` müssen raus: sie trennen Parameter bzw. stehen für
|
|
// ein Leerzeichen. In einem Wert würden sie die Adresse zerreißen.
|
|
let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+"))
|
|
func encode(_ value: String) -> String? {
|
|
value.addingPercentEncoding(withAllowedCharacters: allowed)
|
|
}
|
|
|
|
guard let client = encode(clientIdentifier),
|
|
let code = encode(code),
|
|
let product = encode(product)
|
|
else { return nil }
|
|
|
|
var text = """
|
|
https://app.plex.tv/auth#?clientID=\(client)&code=\(code)\
|
|
&context%5Bdevice%5D%5Bproduct%5D=\(product)
|
|
"""
|
|
|
|
// Strenger kodiert als die übrigen Werte: die Rückadresse enthält
|
|
// `:` und `/`, und die lässt `urlQueryAllowed` durch. Als Wert
|
|
// innerhalb einer Adresse, die Plex selbst noch einmal zerlegt, müssen
|
|
// sie kodiert sein — sonst endet der Wert für Plex beim ersten
|
|
// Schrägstrich.
|
|
let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
|
|
if let forwardURL,
|
|
let forward = forwardURL.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved) {
|
|
text += "&forwardUrl=\(forward)"
|
|
}
|
|
|
|
return URL(string: text)
|
|
}
|
|
}
|