Urbit's identity layer pervades everything on Mars. Unfortunately, despite talking to Mars a lot, Earth is not quite so fortunate. A notable example are requests made to Urbit's HTTP server, Eyre. Presently, when viewing for example a blog post hosted by someone else's urbit, there is no way for a user to leave a comment signed by their own identity right then and there: they need to go through their own urbit instead, maybe even installing a matching blog app.
Here, we propose a mechanism by which HTTP clients may authenticate themselves as a specific urbit on HTTP endpoints served by any other urbit.
Imagine a host ship, ~hoster, serving an interactive web interface, and a visitor, who owns an urbit ship, ~sampel. The visitor wants to use their Urbit identity to interact with ~hoster's web interface.
- Upon loading a web page on ~hoster, the visitor may be redirected to a
login screen. It contains a
@p
input field. The visitor writes~sampel
and presses the "login" button. - On ~hoster, Eyre receives the login request. This prompts it to generate a
nonce
and make a request of ~sampel through remote scry: asking for ~sampel's public-facing URL,sampel-url
. - ~sampel knows what hostname and port it was last authenticated through, and responds with that.
- ~hoster receives ~sampel's URL, and can now construct the response to the
login request; a redirect to ~sampel at:
[sampel-url]?server=~hoster&nonce=[nonce]
. - Visitor's client follows the redirect.
- ~sampel receives the HTTP request and serves a confirmation page. It
displays the local identity (~sampel) and the login target (~hoster),
perhaps explains the dangers, and asks the visitor for confirmation.
- Of course, crucially, this page is only accessible using a valid local authentication cookie.
- Visitor confirms the remote login.
- ~sampel generates a
secret
and sends it to ~hoster via Ames, alongside thenonce
, signalling that the secret proves the visitor's identity. - ~hoster responds to the Ames message with its own public-facing URL,
hoster-url
, to which the visitor must return. - ~sampel receives ~hoster's URL, and can now serve a redirect to
[hoster-url]?nonce=[nonce]&secret=[secret]
. - Visitor's client follows the redirect.
- ~hoster receives the incoming request, confirms that the login
nonce
andsecret
match and were proven over Ames, and grants a fresh session cookie that is associated with the ~sampel identity. Eyre may have stored an initial redirect target when the login flow started. The response that grants the cookie may include a redirect to that target, putting the visitor seamlessly back into the flow that prompted the login in the first place.
sequenceDiagram
autonumber
actor visitor
note right of visitor: visitor starts log in to ~hoster as ~sampel
visitor->>~hoster: POST https://hoster.com/~/login, ship=~sampel
activate ~hoster
~hoster->>~sampel: scry: /e/x/eauth/url
~sampel->>~hoster: data: `'https://sampel.net/~/eauth'
~hoster->>visitor: 303 https://sampel.net/~/eauth?server=~hoster&nonce=abc
deactivate ~hoster
visitor->>~sampel: GET https://sampel.net/~/eauth?server=~hoster&nonce=abc
~sampel->>visitor: 200
note right of visitor: visitor approves log in
visitor->>~sampel: POST https://sampel.net/~/eauth?server=~hoster&nonce=abc, approve=true
activate ~sampel
~sampel->>~hoster: [%eauth-open nonce=abc secret=xyz]
~hoster->>~sampel: [%eauth-okay nonce=abc url='https://hoster.com/~/eauth']
~sampel->>visitor: 303 https://hoster.com/~/eauth?nonce=abc&secret=xyz
deactivate ~sampel
visitor->>~hoster: GET https://hoster.com/~/eauth?nonce=abc&secret=xyz
activate ~hoster
~hoster->>visitor: 200 + cookie + maybe 303
deactivate ~hoster
(Note here that steps 1 through 6 are all optional, it is perfectly reasonable for an EAuth login to be initiated from the client side as well.)
When the requester provides a valid session cookie, Eyre may now associate a
non-local, non-fake1 identity with that request. It can use this for internal
permission checks, and can set the src.bowl
to contain that identity if a
request gets handled by userspace.
Likewise, Eyre channels created & used in this way behave as normal, except
that the pokes and watches they send will have the foreign identity in the
src.bowl
when they get processed by the agent.
Because the sessions do not live on the visitor's own urbit, they will never have absolute control over their lifecycles. But a well-behaved host will still help facilitate session management on the visitor's behalf.
When a new session is created, and no session for that @p
existed on the host
previously, the host should send a notification to that @p
over ames, telling
it that there are now active sessions for it on the host. When a session gets
removed (due to expiry or logout), and it was the last remaining session for
that @p
, it should tell the @p
over ames that there are no active sessions
for it remaining on the host.
This lets the visitor's urbit track all the places across the network that it is logged in to, for display in Landscape or other system management apps. Hosts should respect requests by the visitor's urbit (again, over ames) to "log out all sessions".
The login screen for this should probably be different from the normal login screen, or at least the login page should have clearly different "modes", for whether to allow remote login or not. Presently, unauthenticated requests usually get redirected to the login page, but there is no guarantee that the app that served the redirect actually accepts requests from non-local identities. As such, when redirecting to a login page, the place that generated the redirect should be able to indicate whether remote login should be an option or not.
As with the open eyre proposal1, we intentionally continue giving 403
responses for non-locally-authenticated requests to the /~/scry
endpoint.
Previously envisioned approaches and older iterations of this proposal were vulnerable to man-in-the-middle impersonation attacks. In the iteration presented here, because both the ~hoster and ~sampel ships confirm their public URLs over Ames, a third party cannot cause redirects to illicit locations.
It is important that this kind of negotiation goes both ways over Ames. The initial request can always come from an unauthenticated HTTP request. "I want to log in as ~sampel", by itself, provides no guarantee whatsoever that that request is legitimate... but it does kick off the login flow. So not only does the client ship need to approve the remote login over Ames, the server ship needs to provide a final eauth URL to the client over Ames.
In a world where this URL is provided through a URL query parameter instead, a malicious ~hosten might impersonate ~hoster, and gain access into ~hoster with ~sampel's credentials:
sequenceDiagram
autonumber
actor visitor
visitor->>~hosten: POST https://hosten.com/~/login, ship=~sampel
activate ~hosten
~hosten->>~hoster: POST https://hoster.com/~/login, ship=~sampel
activate ~hoster
~hoster->>~sampel: [%eauth-gib nonce=xyz]
~sampel->>~hoster: [%eauth-url nonce=xyz url='sampel.net']
~hoster->>~hosten: 303 https://sampel.net/~/eauth?server=~hoster&nonce=xyz&return=hoster.com
deactivate ~hoster
note left of ~hosten: note the server and return address
~hosten->>visitor: 303 https://sampel.net/~/eauth?server=~hoster&nonce=xyz&return=hosten.com
deactivate ~hosten
visitor->>~sampel: GET https://sampel.net/~/eauth?server=~hoster&nonce=xyz&return=hosten.com
note right of visitor: visitor approves log in, not noticing the malicious return url
rect rgba(200,100,100,0.2)
visitor->>~sampel: POST https://sampel.net/~/eauth?server=~hoster&nonce=xyz&return=hosten.com, approve=true
activate ~sampel
~sampel->>~hoster: [%eauth-fin nonce=xyz secret=zzz]
~sampel->>visitor: 303 https://hosten.com/~/eauth?client=~sampel&nonce=xyz&secret=zzz
deactivate ~sampel
visitor->>~hosten: GET https://hosten.com/~/eauth?client=~sampel&nonce=xyz&secret=zzz
note left of ~hosten: /!\ ~hosten has now learned the ~sampel->~hoster eauth secret /!\
end
activate ~hosten
~hosten->>~hoster: GET https://hoster.com/~/eauth?client=~sampel&nonce=xyz&secret=zzz
~hoster->>~hosten: 200 + cookie + maybe 303
deactivate ~hosten
Earlier versions of this proposal did not use remote scry, and instead had ~hoster send an Ames message to ~sampel in response to an unauthenticated HTTP request. This is obviously fertile ground for abuse, especially in an implementation where each attempt would get its own Ames flow.
The current design only formally initiates attempts from the client-side, and so only in response to authenticated HTTP requests. ~hoster may still be made to store some state through unauthenticated requests, but it's not permanent state, and can and should be cleaned up in response to memory pressure.
Eyre knows which port it itself is serving on, and may be aware of any number of domain names that resolve to itself. It also knows up to a single SSL certificate. Under specific combinations of these, it is trivial for Eyre to know how it can be accessed over HTTP/S. But this is not always the case, and even if it was, there is no guarantee the urbit is not running behind some proxy server setup that is accessible elsewhere.
For constructing the base-url
from (2), Eyre remembers simply the security,
hostname and port whenever it gets logged in to by the local identity. Given
that the user needs to log in to their urbit to approve the EAuth attempt
anyway, this is not a crazy restriction. Of course, the user also may provide
Eyre with a manually-configured URL to use instead.
For (9), we do something similar, but instead base it off the incoming HTTP request in (1). Of course, since that entire start to the flow is optional, we fall back to the logic from the above paragraph if (1) never occurred.
Between (1)-(4) and (6)-(7) the server needs to wait for receipt of an Ames message. In cases where this takes a long time to come through (or does not arrive at all) Eyre should be careful to manually serve a legible 504 response before the connection times out.
...How long it takes for the connection to time out is dependent on the client, and appears to vary greatly between browsers. A safe bet would be to time out explicitly after the amount of time it takes for the UX to be degraded beyond repair. That is, probably less than a minute.
Connection
and Keep-Alive
headers are prohibited in HTTP/2 and /3, so we
may not want to rely on those to indicate we desire a more-predictable amount
of time.
It may or may not be important to know how "real" the src.bowl
is. Whether
it came from Ames, or through Eyre. If it ends up mattering, the userspace
provenance proposal2 may provide a good way for agents to find out.
:: +authentication-state: state used in the login system
::
+$ authentication-state
$: :: sessions: a mapping of session cookies to session information
::
sessions=(map @uv session)
:: visitors: in-progress incoming eauth flows
::
visitors=(map @uv visitor)
:: visiting: outgoing eauth sessions, completed or pending
::
visiting=(map ship (map @uv portkey))
:: endpoint: hardcoded local eauth endpoint for %syn and %ack
::
:: user-configured or auth-o-detected, with last-updated timestamp.
:: both shaped like 'prot://host'
::
endpoint=[user=(unit @t) auth=(unit @t) =time]
==
:: +visitor: completed or in-progress incoming eauth flow
::
:: duct: boon duct
:: and
:: sesh: login completed, session exists
:: or
:: pend: awaiting %tune for %keen sent at time, for initial eauth http req
:: ship: the @p attempting to log in
:: base: local protocol+hostname the attempt started on, if any
:: last: the url to redirect to after log-in
:: toke: authentication secret received over ames or offered by visitor
::
+$ visitor
$: duct=(unit duct)
$@ sesh=@uv
$: pend=(unit [http=duct keen=time])
ship=ship
base=(unit @t)
last=@t
toke=(unit @uv)
== ==
:: +portkey: completed or in-progress outgoing eauth flow
::
:: made: live since
:: or
:: duct: confirm request awaiting redirect
:: toke: secret to include in redirect, unless aborting
::
+$ portkey
$@ made=@da :: live since
$: pend=(unit duct) :: or await redir
toke=(unit @uv) :: with secret
==
:: +eauth-plea: client talking to host
::
+$ eauth-plea
$: %0
$% :: %open: client decided on an attempt, wants to return to url
:: %shut: client wants the attempt or session closed
::
[%open nonce=@uv token=(unit @uv)]
[%shut nonce=@uv]
== ==
:: +eauth-boon: host responding to client
::
+$ eauth-boon
$: %0
$% :: %okay: attempt heard, client to finish auth through url
:: %shut: host has expired the session
::
[%okay nonce=@uv url=@t]
[%shut nonce=@uv]
== ==