Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions components/config/SigninScene.bs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ sub onAlertChange()

errorMessage = m.top.findNode("errorMessage")
errorMessage.text = m.top.alert

' Some messages run to several lines, so the box grows to hold them instead of covering the first.
textHeight = errorMessage.boundingRect().height
m.errorContainer.height = textHeight + 26
if m.errorContainer.height < 60 then m.errorContainer.height = 60
end sub

sub onUserChange()
Expand Down Expand Up @@ -282,6 +287,12 @@ sub onQuickConnectAuthenticated()

if authenticated >= 0 then return

if authenticated = -2
m.top.alert = signInRefusedMessage()
hideQuickConnect()
return
end if

' A poll that can never succeed looks the same as one still waiting to be approved, so
' the wait is bounded rather than left running until the viewer walks away
m.quickConnectAttempts = m.quickConnectAttempts + 1
Expand Down
2 changes: 1 addition & 1 deletion components/config/SigninScene.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
uri="pkg:/images/surface.9.png">

<Rectangle id="errorContainer" visible="false" width="600" height="60" translation="[305, 625]">
<Text id="errorMessage" text="" wrap="true" width="600" font="font:SmallSystemFont" horizAlign="center" color="0xffe0e0" translation="[10, 13]" />
<Text id="errorMessage" text="" wrap="true" width="580" font="font:SmallSystemFont" horizAlign="center" color="0xffe0e0" translation="[10, 13]" />
</Rectangle>

<Label id="signInTitle"
Expand Down
9 changes: 7 additions & 2 deletions components/quickConnect/QuickConnect.bs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ sub monitorQuickConnect()
authenticated = checkQuickConnect(m.top.secret)

if authenticated = true
loggedIn = AuthenticateViaQuickConnect(m.top.secret)
if loggedIn
result = AuthenticateViaQuickConnect(m.top.secret)
if result.refused
' Polling again would only be refused again.
m.top.authenticated = -2
return
end if
if result.ok
session.setRuntimeRequestContextValue("moonfin.authToken", m.global.session.user.authToken)

currentUser = AboutMe()
Expand Down
5 changes: 5 additions & 0 deletions locale/en_US/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7120,5 +7120,10 @@
<translation>External Lists</translation>
<extracomment>Heading of a group of disabled rows in the Home Rows editor</extracomment>
</message>
<message>
<source>This account can&apos;t sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.</source>
<translation>This account can&apos;t sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.</translation>
<extracomment>Shown when the server refuses a sign in because the account can't be used right now, for example outside its access schedule</extracomment>
</message>
</context>
</TS>
41 changes: 25 additions & 16 deletions source/api/userauth.bs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ function validPasswordLogin(response as object) as boolean
return embyConnect.StringValue(json.User, ["Id"]) <> "" and embyConnect.StringValue(json.User, ["Name"]) <> ""
end function

' Jellyfin refuses a sign in with a 403 when the account can't be used right now, for instance
' outside the hours of its access schedule, and gives no reason beyond that. A wrong password is
' a 401, but the account is checked first, so such an account gets this for any password.
function signInRefusedMessage() as string
return tr("This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.")
end function

function passwordLoginError(status as integer) as string
if status = 401 or status = 403
if isEmbyServer() then return tr("Emby server rejected the login. Use the local server account, or choose Emby Connect for your Emby account.")
if status = 403 then return signInRefusedMessage()
return tr("Username or password is incorrect, or access is denied.")
end if
if status = 0 then return tr("The server did not respond. Check the connection and try again.")
Expand Down Expand Up @@ -248,23 +256,24 @@ function checkQuickConnect(secret)
return false
end function

function AuthenticateViaQuickConnect(secret)
params = {
secret: secret
}
req = APIRequest("Users/AuthenticateWithQuickConnect")
jsonResponse = postJson(req, FormatJson(params))
if jsonResponse <> invalid and jsonResponse.AccessToken <> invalid and jsonResponse.User <> invalid
userdata = CreateObject("roSGNode", "UserData")
userdata.json = jsonResponse
session.user.Update("id", jsonResponse.User.Id)
session.user.Update("authToken", jsonResponse.AccessToken)
userdata.callFunc("saveToRegistry")

return true
end if
' Exchanges an approved Quick Connect secret for a session. refused is true when the server turned
' the account away, which it does with a 403 and nothing in the body.
function AuthenticateViaQuickConnect(secret) as object
serverUrl = get_url()
header = buildAuthHeaderForServer({ serverUrl: serverUrl, userId: "", authToken: "" })
headers = { "Content-Type": "application/json", "Authorization": header }
if isEmbyServer(serverUrl) then headers["X-Emby-Authorization"] = header
url = buildURLForServer(serverUrl, "Users/AuthenticateWithQuickConnect", {})
response = embyConnect.Request(url, "POST", FormatJson({ secret: secret }), headers)
if not validPasswordLogin(response) then return { ok: false, refused: response.status = 403 }

return false
jsonResponse = response.data
userdata = CreateObject("roSGNode", "UserData")
userdata.json = jsonResponse
session.user.Update("id", jsonResponse.User.Id)
session.user.Update("authToken", jsonResponse.AccessToken)
userdata.callFunc("saveToRegistry")
return { ok: true, refused: false }
end function

sub forgetServerIdentity(serverUrl as dynamic, removedUserId as string)
Expand Down
1 change: 1 addition & 0 deletions test/server-compatibility.bs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ sub Main()
testReviewRegressions()
testAccentFolding()
testQuickConnectUrl()
testSignInRefused()
testLogRedaction()
testYoutubeTrailer()
testPlaybackFallback()
Expand Down
3 changes: 2 additions & 1 deletion test/server-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const selections = {
'components/captionTask.bs': ['trackAddress'],
'components/ItemGrid/LoadVideoContentTask.bs': ['playbackResourceURL', 'resolvePlaybackURL', 'playbackPort', 'normalizedPlaybackPort', 'playbackUsesServerAuth', 'isHTTPStream', 'getTranscodeReasons', 'addVideoContentURL', 'addSourceCodecs', 'shouldBurnInSubtitle', 'transcodeCopiesVideo', 'audioPlaysDirect', 'containersOverlap'],
'components/ItemGrid/LoadItemsTask2.bs': ['getTargetImageURL', 'getTargetServerUrl', 'isUsingRemoteServer'],
'source/api/userauth.bs': ['passwordLoginRequest', 'validPasswordLogin', 'passwordLoginError'],
'source/api/userauth.bs': ['passwordLoginRequest', 'validPasswordLogin', 'passwordLoginError', 'signInRefusedMessage', 'AuthenticateViaQuickConnect'],
'source/utils/config.bs': ['current_user_id', 'get_user_setting'],
'source/utils/misc.bs': ['isLocalhost', 'isSupportedMediaServer', 'urlCandidates', 'isValid', 'isAllValid', 'isStringEqual', 'isChainValid', 'chainLookupReturn', 'chainLookup', 'isValidAndNotEmpty', 'serverVersionMeetsMinimumRequirements', 'playlistRenumbersAfterDelete', 'toString', 'getHomeBackdropBlurAmount', 'inArray', 'guidKey', 'isString', 'toBoolean', 'leftPad'],
'source/ShowScenes.bs': ['ServerVersionCheck', 'startDetailExtras'],
Expand Down Expand Up @@ -131,6 +131,7 @@ source += '\n' + await readFile('test/server-compatibility.bs', 'utf8');
source += '\n' + await readFile('test/review-regressions.bs', 'utf8');
source += '\n' + await readFile('test/accent-folding.bs', 'utf8');
source += '\n' + await readFile('test/quick-connect-url.bs', 'utf8');
source += '\n' + await readFile('test/sign-in-refused.bs', 'utf8');
source += '\n' + await readFile('test/log-redaction.bs', 'utf8');
source += '\n' + await readFile('test/youtube-trailer.bs', 'utf8');
source += '\n' + await readFile('test/playback-fallback.bs', 'utf8');
Expand Down
21 changes: 21 additions & 0 deletions test/sign-in-refused.bs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
sub testSignInRefused()
m.global.session.server = { url: "https://jellyfin.test", serverType: "jellyfin" }
refused = signInRefusedMessage()
assertEqual(passwordLoginError(403), refused, "A 403 from Jellyfin says the account can't sign in right now")
assertEqual(passwordLoginError(401) <> refused, true, "A wrong password keeps its own message")
assertEqual(passwordLoginError(500).Instr("500") >= 0, true, "Other failures keep their messages")
assertEqual(refused.Instr("allowed hours") >= 0, true, "The message names the schedule as one possible reason")

m.requests = []
m.responses = [{ status: 403, data: invalid }]
result = AuthenticateViaQuickConnect("secret")
assertEqual(result.refused, true, "A refused Quick Connect exchange is reported as refused")
assertEqual(result.ok, false, "A refused Quick Connect exchange does not sign in")
assertEqual(m.requests[0].url, "https://jellyfin.test/Users/AuthenticateWithQuickConnect", "The exchange goes to the Quick Connect route")
assertEqual(ParseJson(m.requests[0].body).secret, "secret", "The exchange sends the approved secret")

m.responses = [{ status: 500, data: invalid }]
result = AuthenticateViaQuickConnect("secret")
assertEqual(result.refused, false, "A server error is not a refusal")
assertEqual(result.ok, false, "A server error does not sign in")
end sub
Loading