Processing a login initiation request
All domains, identifiers, and values in these examples are fictional.
The portal's login initiation endpoint is registered, and the dashboard's tiles point at it. Now the portal's developers write the code behind /signin/start.
The request it handles looks official. It came from Lantern's dashboard, it names Lantern's identity provider, and it points at a real project. Nothing in it proves any of that.
Anyone can send this link
A login initiation request is a URL, or an automatically submitted form, with a few plain parameters. It carries no signature and no client authentication, and nothing in it identifies who built it. It arrives through the designer's browser, which may have been sent there by Lantern's dashboard, by a link in an email, or by any page on the web. Anyone can write this one:
https://portal.lantern.example/signin/start
?iss=https%3A%2F%2Fidp.lantern-sso.example
&login_hint=designer-42
&target_link_uri=https%3A%2F%2Fportal-lantern.example%2Fsession-expired
At a glance it resembles the dashboard's request. The issuer is a lookalike domain that the attacker controls, and so is the target. A portal that trusted both values would send the designer to the attacker's sign-in page, which could imitate Lantern's perfectly. Afterward it would send them to the attacker's site, which could claim the session had expired and ask for the designer's password again.
The portal's task is to take from the request only what it can check against its own configuration, and to treat the rest as suggestions it may ignore. Each parameter gets its own decision.
Choosing the provider
The portal accepts iss only if it exactly matches the issuer identifier of a provider the portal is already configured to use, compared character for character, as Issuer, audience, and authorized party compared the iss claim. For Lantern's portal the list has one entry, https://idp.lantern.example. A request naming any other issuer, including the lookalike above, starts no sign-in. The portal shows its ordinary sign-in page instead, where the designer can begin in the usual way.
A match selects configuration the portal already holds for that provider: its authorization and token endpoints, its key set, and the portal's own client ID and credentials there. The portal never runs discovery for an issuer it has just read from a request. Provider discovery said that trust starts from an issuer the relying party chose, never one taken from a token or a callback, and a login initiation request is one more place where an issuer can be offered. Fetching configuration for an arbitrary issuer would let anyone choose where the designer signs in, and would let anyone make the portal's servers send requests to addresses of their choosing.
The matched issuer becomes the expected issuer of the pending sign-in, exactly as when someone chooses a provider on the portal's own page. If the portal supports several providers, the protections from Supporting several providers apply unchanged: the response and the ID token must come from the issuer this attempt expected.
login_hint needs less care, because it grants nothing. The portal can pass it to the provider in its authentication request, where, as Login hints explained, it only suggests an account. The provider still authenticates whoever is at the browser. The portal checks that the value is short and well formed before copying it into a URL, and drops it otherwise. A hint naming someone else's account cannot sign the designer in as that person. At most, the provider suggests the wrong account on its sign-in page.
Checking the target
target_link_uri is the parameter the specification singles out. The relying party must verify it, so that its login initiation endpoint cannot be used as an open redirector to other sites. Redirect URI validation described open redirects and why attackers value them: a link that begins with a trusted address and ends on the attacker's page. This one is especially convincing, because the designer would reach the attacker's page straight after a genuine sign-in, at the moment they have every reason to trust the next page.
The portal checks the target against the pages it is willing to show:
- It parses the value with a standard URL parser and requires the scheme
httpsand its own host,portal.lantern.example, compared exactly. A prefix check fails here for the same reasons it failed for redirect URIs.https://portal.lantern.example.attacker.example/begins with the right characters and belongs to someone else. - It requires the path to be one of the portal's own pages, such as a project page, from a list or pattern the portal maintains.
- If the target is missing or fails either check, it ignores the target and lands on the portal's home page after sign-in. The target was only a preference, so there is no need to refuse the whole sign-in.
The portal keeps the checked path with the pending sign-in on its own server, not in state, as Correlating requests and responses advised for return destinations. After the callback, it reads the destination from the pending sign-in, never from the callback itself.
Reaching the target is not the same as being allowed to see it. The autumn catalogue page still checks, as every project page does, that the signed-in designer belongs to that project. A link from the dashboard says where the designer wanted to go. The portal's own permissions decide what they find there.
Starting a fresh request
With a provider chosen and a destination checked, the portal does exactly what its own sign-in button does. It creates a pending sign-in for this browser:
Pending sign-in: demo-portal-signin-4
Initiating session: the current, not yet signed-in portal session
Expected issuer: https://idp.lantern.example
Redirect URI: https://portal.lantern.example/signin/callback
Nonce: demo-portal-nonce-4
PKCE verifier: held privately on the backend
Return destination: /projects/autumn-catalogue
Status: pending, with a limited lifetime
Then it redirects the browser to Lantern's identity provider with an ordinary authentication request:
https://idp.lantern.example/authorize
?response_type=code
&client_id=lantern-portal
&redirect_uri=https%3A%2F%2Fportal.lantern.example%2Fsignin%2Fcallback
&scope=openid%20profile
&state=demo-portal-signin-4
&nonce=demo-portal-nonce-4
&code_challenge=N746XYTHj5ctZ7qbJbjsq9xKqCn-uPhdKT7Qrm3clys
&code_challenge_method=S256
&login_hint=designer-42
Readable values help us follow the example. Real ones are fresh and unpredictable, and every one of them is created here, by the portal. The login initiation request supplied none of them, and if it had carried parameters named state or nonce, the portal would ignore them, because they would be values someone else chose. From here on the sign-in is an ordinary one. The callback must match this pending sign-in in this browser, and the ID token is validated as in Validating an ID token, nonce included.
Sometimes the designer already has a portal session from earlier in the day. The portal can then skip the round trip and go straight to the checked target, provided that session came from the issuer the request named and belongs to the account the hint names. Here the hint is a subject identifier, so the portal can compare it with the session's subject directly. If they differ, the portal starts a fresh sign-in rather than opening whichever account happens to be signed in. The login initiation request itself never changes who is signed in. Only a completed, validated sign-in like this one can do that.
One risk remains. Any website can send the designer's browser to the login initiation endpoint, and because Lantern's identity provider remembers the designer, the whole exchange can complete without showing a single page. The designer can end up signed in to the portal without having chosen to. That gives no one anything on its own. Combined with clickjacking, though, it matters: a page loads the portal inside an invisible frame and tricks the designer into clicking buttons they cannot see, acting in the portal with the designer's new session. The specification asks relying parties to defend against this. Browsers that withhold cookies from frames on other sites already make it harder, but the portal should not depend on that. The standard defense is a response header that forbids other sites from framing the portal's pages at all:
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
Current browsers follow the first header, and the second covers older ones. The portal sends them with every page, including the login initiation endpoint and the sign-in callback.