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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`username` column, its uniqueness constraint and its registration validation — AuthKit does not own
the users table and ships no migration for it. See `docs/login.md`. Closes
[#65](https://github.com/laranail/authkit/issues/65).
- **Explicit, secure identity linking.** `IdentityLinkingInterface` lets a provider package attach a
sign-in method to an already authenticated user without that path ever being able to sign anyone
in. A link attempt requires a recently confirmed credential, carries a single-use token bound to
the initiating user, guard, provider and authentication context, and refuses an external identity
owned by another account — never switching the current user and never transferring the identity.
The surface accepts no email address at all, so a matching address cannot become authorisation.
See `docs/identity-linking.md`. Closes [#64](https://github.com/laranail/authkit/issues/64).
- The two-factor middleware is registered under the vendor-scoped alias `laranail-authkit-two-factor`
(`AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE`). `NamingConventionTest` asserts it, and that no
other bare alias is registered, against the live router.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Full documentation: <https://opensource.simtabi.com/documentation/laranail/authk
- [Profile management](docs/profile-management.md) · [Email verification](docs/email-verification.md)
- [Browser sessions](docs/browser-sessions.md) · [API routes](docs/api-routes.md) · [API tokens](docs/api-tokens.md)
- [Two-factor authentication](docs/two-factor-authentication.md)
- [Identity linking](docs/identity-linking.md)
- [Social login](https://github.com/laranail/authkit-social-login/blob/main/docs/social-login.md) · [Passkeys](docs/passkeys.md) · [API tokens](docs/api-tokens.md)

### Project
Expand Down
10 changes: 10 additions & 0 deletions config/laranail/authkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
'identifiers' => ['email'],
],

'identity_linking' => [
/*
* How long a link attempt stays valid.
*
* It spans one redirect to an external provider and back, so minutes rather than hours.
* The attempt is single-use regardless; this bounds how long an unused one lingers.
*/
'intent_ttl_minutes' => (int) env(key: 'AUTHKIT_LINK_INTENT_TTL_MINUTES', default: 10),
],

'rate_limit' => [
'max_attempts' => (int) env(key: 'AUTHKIT_RATE_LIMIT_MAX_ATTEMPTS', default: 5),
'decay_minutes' => (int) env(key: 'AUTHKIT_RATE_LIMIT_DECAY_MINUTES', default: 1),
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ php artisan vendor:publish --tag=laranail::authkit-config
Set `AUTHKIT_GUARD` to the application's intended guard. The `laranail.authkit.fortify.features` list enables Fortify capabilities individually: `reset-passwords`, `update-profile-information`, `update-passwords`, `email-verification`, and `passkeys`. Removing an item prevents Auth Kit from enabling that capability; remove corresponding application routes and UI too.

The `laranail.authkit.login` block chooses how a user identifies themselves. `field` is the request input the identifier arrives in (`email` by default, `AUTHKIT_LOGIN_FIELD`), and `identifiers` lists the user columns it is matched against, in order (`['email']` by default). See [Login](login.md#signing-in-with-a-username).
`laranail.authkit.identity_linking.intent_ttl_minutes` bounds how long an unstarted link attempt lingers (`AUTHKIT_LINK_INTENT_TTL_MINUTES`, ten minutes by default). See [Identity linking](identity-linking.md).

Credential throttling is controlled by `AUTHKIT_RATE_LIMIT_MAX_ATTEMPTS` and `AUTHKIT_RATE_LIMIT_DECAY_MINUTES`, defaulting to five attempts per minute. Social credentials, callbacks, and enabled providers belong to [`laranail/authkit-social-login`](https://github.com/laranail/authkit-social-login); see its [social login guide](https://github.com/laranail/authkit-social-login/blob/main/docs/social-login.md).

Expand Down
128 changes: 128 additions & 0 deletions docs/identity-linking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Identity linking

Adding a sign-in method to an account that is already signed in, without that ever becoming a way
to sign in.

`Simtabi\Laranail\AuthKit\Contracts\IdentityLinkingInterface` is the mechanism, bound to
`Services\IdentityLinking`. A provider package — `laranail/authkit-social-login` and its siblings —
owns where links are stored and which external identity it just saw. This owns whether attaching
one is allowed.

## Why it is separate from signing in

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

So the two paths are kept apart, structurally rather than by convention:

- Nothing here can authenticate anybody. The service has no access to a guard's login, and every
method returns a value object or nothing.
- Nothing here accepts an email address. There is no parameter for one and no field on
`LinkIntent`, so a matching address cannot become authorisation even by mistake. A test asserts
that shape rather than the behaviour.

## The flow

```php
use Simtabi\Laranail\AuthKit\Contracts\IdentityLinkingInterface;
use Simtabi\Laranail\AuthKit\Exceptions\IdentityLinkException;

// 1. The user asks to connect a provider. Refused unless a credential was confirmed recently.
$token = app(IdentityLinkingInterface::class)->issueIntent(
request: $request,
user: $request->user(),
guard: 'web',
provider: 'github',
);

// 2. Carry $token through the provider redirect as your own state, and on the way back:
$linking = app(IdentityLinkingInterface::class);

try {
$linking->consumeIntent($request, $token, $request->user(), 'web', 'github');
$linking->assertLinkable($request->user(), $ownerOfThisExternalIdentity);
} catch (IdentityLinkException $exception) {
return back()->withErrors(['connection' => $exception->getMessage()]);
}

// 3. Only now does your package write the link.
```

`$ownerOfThisExternalIdentity` is whoever already holds the identity, or `null` when nobody does.
The provider package looks that up, because it owns the table.

## What each step guarantees

| Step | Refuses |
|---|---|
| `issueIntent` | No credential confirmed within `auth.password_timeout` |
| `consumeIntent` | A token already spent, unknown, or expired |
| `consumeIntent` | A different user, guard, provider or authentication context than the one that started it |
| `assertLinkable` | An identity owned by a different account |

`assertLinkable` is idempotent for an identity the same user already owns, so a double submission
is harmless. It never switches the current user and never transfers an identity between accounts:
the only outcomes are "allowed" and an exception.

## Recent reauthentication

A browser session answers through Laravel's own `auth.password_confirmed_at`, checked against
`auth.password_timeout` — exactly what the framework's `password.confirm` middleware maintains, so
an application that already confirms passwords needs nothing new. Put `password.confirm` on the
route that starts a link and this is satisfied before the service is reached.

A token-authenticated client has no session to carry that, so it proves the credential on the
request that starts the link:

```php
POST /api/auth/connections/github { "password": "..." }
```

The password is checked through the guard's own provider, so an application that has replaced its
user provider keeps its own rules.

## Why the attempt is single-use and bound

The token is 256 bits of randomness, and only its SHA-256 is stored — the token itself exists only
in the response that returned it. The record binds the user, their model class, the guard, the
provider and a hash of the session or access token the attempt began from, and all of them are
compared again on the way back.

Spending is a read-and-delete taken under an atomic lock where the cache store offers one. A
replayed callback therefore finds nothing, which is the refusal that matters: without it, one
intercepted callback URL could be replayed to attach the same identity repeatedly, or attach it
after the user believed the attempt had failed.

A client presenting neither a session nor an access token binds on the user alone. That is weaker,
but it still cannot cross accounts, because the user is compared too.

## Failure handling

`IdentityLinkException::$failure` is a closed `IdentityLinkFailure` enum, so a caller branches on
the reason rather than parsing a message — a web flow wants to redirect to password confirmation
where an API wants a 403, and both are the same refusal.

| Case | Meaning |
|---|---|
| `RecentAuthenticationRequired` | Confirm a password, then start again |
| `IntentInvalid` | Unknown, expired, or already spent |
| `IntentMismatch` | Finished by a different user, guard, provider or context |
| `IdentityOwnedByAnotherUser` | Already connected elsewhere; nothing was changed |

## Configuration

```php
// config/laranail/authkit.php
'identity_linking' => [
'intent_ttl_minutes' => 10,
],
```

The attempt spans one redirect out to a provider and back, so minutes rather than hours. It is
single-use regardless; this only bounds how long an unused one lingers.

---

[← Docs index](../README.md#documentation)
52 changes: 52 additions & 0 deletions src/Contracts/IdentityLinkingInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Contracts;

use Illuminate\Http\Request;
use Illuminate\Contracts\Auth\Authenticatable;
use Simtabi\Laranail\AuthKit\Support\LinkIntent;

/**
* Adding a sign-in method to an account that is already signed in.
*
* Deliberately separate from signing in: nothing here authenticates anybody, and no method takes
* an email address, so a matching address can never become authorisation. A provider package owns
* where links are stored; this owns whether one is allowed.
*/
interface IdentityLinkingInterface
{
/**
* Begin a link attempt and return its single-use token.
*
* Refuses unless a credential was confirmed recently -- a session that has been through
* password confirmation, or a password supplied on this request for a client that has no
* session. The returned token is the only copy; only a hash of it is stored.
*
* @throws \Simtabi\Laranail\AuthKit\Exceptions\IdentityLinkException
*/
public function issueIntent(Request $request, Authenticatable $user, string $guard, string $provider): string;

/**
* Spend the token, and prove the attempt finishing is the attempt that started.
*
* Consuming is single-use: a replayed callback finds nothing. The user, guard, provider and
* authentication context are all compared against what was recorded, so a callback landing on
* a different session or a different account is refused rather than applied.
*
* @throws \Simtabi\Laranail\AuthKit\Exceptions\IdentityLinkException
*/
public function consumeIntent(Request $request, string $token, Authenticatable $user, string $guard, string $provider): LinkIntent;

/**
* Refuse an external identity that belongs to somebody else.
*
* `$currentOwner` is whoever already holds this identity, or null when nobody does -- the
* provider package looks that up, because it owns the table. The same user is idempotent; a
* different user is refused, never switched and never transferred.
*
* @throws \Simtabi\Laranail\AuthKit\Exceptions\IdentityLinkException
*/
public function assertLinkable(Authenticatable $user, ?Authenticatable $currentOwner): void;
}
36 changes: 36 additions & 0 deletions src/Enums/IdentityLinkFailure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Enums;

/**
* Why a link attempt was refused.
*
* A closed set, so a caller rendering a message handles every case and a new reason cannot be
* introduced without every `match` over it being revisited.
*/
enum IdentityLinkFailure: string
{
/** No credential was confirmed recently enough to add a new way of signing in. */
case RecentAuthenticationRequired = 'recent_authentication_required';

/** The intent is unknown, expired, or has already been consumed. */
case IntentInvalid = 'intent_invalid';

/** The intent was issued to a different user, guard, provider, or authentication context. */
case IntentMismatch = 'intent_mismatch';

/** The external identity already belongs to a different account. */
case IdentityOwnedByAnotherUser = 'identity_owned_by_another_user';

public function message(): string
{
return match ($this) {
self::RecentAuthenticationRequired => 'Confirm your password before adding a sign-in method.',
self::IntentInvalid,
self::IntentMismatch => 'This linking request is no longer valid. Start again.',
self::IdentityOwnedByAnotherUser => 'That account is already connected to a different user.',
};
}
}
28 changes: 28 additions & 0 deletions src/Exceptions/IdentityLinkException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Exceptions;

use RuntimeException;
use Simtabi\Laranail\AuthKit\Enums\IdentityLinkFailure;

/**
* A link attempt was refused.
*
* The reason is a closed enum rather than a message, so a caller can branch on it -- a web flow
* redirecting to a password-confirmation page and an API returning 403 want different handling of
* the same refusal -- without parsing prose.
*/
final class IdentityLinkException extends RuntimeException
{
private function __construct(public readonly IdentityLinkFailure $failure)
{
parent::__construct($failure->message());
}

public static function because(IdentityLinkFailure $failure): self
{
return new self($failure);
}
}
1 change: 1 addition & 0 deletions src/Providers/AuthKitServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public function packageRegistered(): void
$this->app->bind(abstract: Contracts\CheckEmailExistsInterface::class, concrete: Actions\CheckEmailExists::class);
$this->app->bind(abstract: Contracts\FindUserByEmailInterface::class, concrete: Actions\FindUserByEmail::class);
$this->app->bind(abstract: Contracts\LoginUserInterface::class, concrete: Actions\LoginUser::class);
$this->app->bind(abstract: Contracts\IdentityLinkingInterface::class, concrete: Services\IdentityLinking::class);
$this->app->bind(abstract: Contracts\LogoutUserInterface::class, concrete: Actions\LogoutUser::class);
$this->app->bind(abstract: Contracts\IssueTokenForUserInterface::class, concrete: Actions\IssueTokenForUser::class);
$this->app->singleton(Contracts\TokenIssuerRegistryInterface::class, function (): Services\TokenIssuerRegistry {
Expand Down
Loading
Loading