Skip to content

Add explicit, secure identity linking for authenticated users - #77

Merged
imanimanyara merged 5 commits into
mainfrom
feat/explicit-identity-linking
Nov 13, 2026
Merged

imanimanyara merged 5 commits into
mainfrom
feat/explicit-identity-linking

Conversation

@imanimanyara

@imanimanyara imanimanyara commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Summary

A provider package had no way to attach a sign-in method to an account except by inferring consent
from the fact that somebody was logged in. This adds the explicit mechanism: IdentityLinkingInterface,
bound to Services\IdentityLinking.

$token = $linking->issueIntent($request, $request->user(), 'web', 'github');
// ... carry $token through the provider redirect, and on the way back:
$linking->consumeIntent($request, $token, $request->user(), 'web', 'github');
$linking->assertLinkable($request->user(), $ownerOfThisExternalIdentity);
// only now does the provider package write the link

AuthKit owns whether a link is allowed. The provider package owns where links are stored and who
currently holds an identity, because it owns the table.

Why linking is not sign-in

Resolving an account from a verified address is how a provider signs you in. It is not good enough
to attach a standing new credential, because the two have different blast radii: a wrong sign-in
ends with a session, a wrong link ends with a way back in that the owner never chose.

The separation is structural, not a convention anyone has to remember:

  • Nothing here can authenticate. No access to a guard's login; every method returns a value
    object or nothing. A test asserts nobody is signed in after a link completes.
  • Nothing here takes an email address. No parameter, no field on LinkIntent. "Do not treat a
    matching email as authorization" is a property of the shape, so a reflection test asserts the
    shape rather than a branch — a branch can drift, a missing parameter cannot.

The four security requirements, and where each lands

Requirement How
Bind the attempt to the initiating user and auth context; single-use state 256-bit token stored only as its SHA-256; the record binds user, model class, guard, provider and a hash of the session or access token, all re-compared on the way back. Spending is read-and-delete under an atomic lock where the store offers one
Require recent reauthentication with an available credential auth.password_confirmed_at against auth.password_timeout for a session — exactly what Laravel's password.confirm maintains — or a password proved on the request for a token client, checked through the guard's own provider
Reject an identity linked to another user; never switch or transfer assertLinkable returns for a free identity, is idempotent for one this user already owns, and otherwise throws. The only outcomes are allowed and an exception
Never treat a matching email as authorization The surface has nowhere to put one

Refusals carry a closed IdentityLinkFailure enum rather than a message, so a web flow redirecting
to password confirmation and an API returning 403 can handle the same refusal differently without
parsing prose.

Verification

Suite 98 passed, 223 assertions (17 new)
laranail-pint --test passed
Coverage asked for by the issue state replay, linking to the wrong user, identity conflicts, successful linking — all present

Proven red before being relied on, four separate ways:

Break Result
Drop the recent-authentication requirement 3 failed
Read the intent without spending it replay case failed
Stop binding the intent to user, context and provider 3 failed
Permit an identity another user owns 1 failed
Restore 17 passed

One of those checks initially reported a false clean: the script's anchor matched twice, so its
edit never applied and the suite stayed green. It is recorded here because the green was my
script not running, not the guard lacking teeth — the corrected run is the row above.

Not in this change

authkit-social-login still resolves identities the old way, and adopting this mechanism there is
authkit-social-login#12.

Reviewing that package raised a separate concern in its existing resolution path. It is not
described here, because that package is public and the concern is unfixed; it has been reported to
the maintainer privately.

Closes #64.

A provider package had no way to attach a sign-in method to an account other
than inferring consent from the fact that somebody was logged in. That conflates
two different decisions: resolving an account from a verified address is how a
provider signs you in, and it is not good enough to attach a standing new
credential, because a wrong sign-in ends with a session and a wrong link ends
with a way back in the owner never chose.

The mechanism keeps them apart structurally rather than by convention. It cannot
authenticate anybody -- no access to a guard's login, every method returns a
value object or nothing -- and it accepts no email address anywhere, so a
matching address cannot become authorisation even by mistake.

An attempt requires a credential confirmed within `auth.password_timeout`, which
for a browser is Laravel's own `auth.password_confirmed_at` and for a token
client is a password proved on the request. It then carries a single-use token,
256 bits stored only as its hash, bound to the user, their model class, the
guard, the provider and the session or access token it began from; all of them
are compared again on the way back. Spending is read-and-delete under an atomic
lock where the store offers one.

An identity owned by another account is refused. The current user is never
switched and the identity is never transferred -- the only outcomes are allowed
and an exception, whose reason is a closed enum so a web flow and an API can
handle the same refusal differently without parsing prose.
The four cases the issue names, plus the ones that make them mean something: a
callback landing on a different session, an intent finished against a different
provider, an aged-out confirmation, and a token client proving its password.

Two assert properties rather than behaviour. One checks that nothing is signed
in after a link completes, which is the whole point of keeping linking separate
from sign-in. The other walks the surface by reflection and asserts no parameter
or field is named for an email address -- "do not treat a matching email as
authorization" is a property of the shape, and a shape is worth asserting
because it cannot drift the way a branch can.

Proven red four ways: dropping the reauthentication requirement fails 3, reading
the intent without spending it fails the replay case, unbinding the intent fails
3, and permitting an identity another user owns fails 1.
States what each step refuses and why the attempt is single-use and bound, so a
provider package implementing a connect flow does not have to infer the contract
from the signatures.

Says plainly why this is separate from signing in: the two paths have different
blast radii, and the separation is structural -- there is nowhere to pass an
address and no way to authenticate.
Copilot AI balanced review requested due to automatic review settings November 13, 2026 12:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

…y-linking

# Conflicts:
#	CHANGELOG.md
#	config/laranail/authkit.php
#	docs/configuration.md
Resolving the merge kept both sides of each conflict, which was right, but the
conflict region in the config ended inside the `login` array rather than after
it. Concatenating the two sides therefore nested `identity_linking` inside
`login` and left the file one `]` short, so it did not parse at all.

Close `login` before `identity_linking` opens. Both are top-level keys again,
which the suite asserts by reading them through the booted container.
@imanimanyara
imanimanyara merged commit 8c51ccd into main Nov 13, 2026
7 checks passed
@imanimanyara
imanimanyara deleted the feat/explicit-identity-linking branch November 13, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add explicit, secure identity linking for authenticated users

2 participants