The nested resource gives tokens their own endpoints, in their own controller. Stop there for a moment, because this is the situation Chapter 4 warned about.
TokenController is a new controller. It sits inside the auth:sanctum group and has no other protection. scoped() guarantees that a token belongs to the consumer in the URL. It says nothing about whether the caller has any business with that consumer. Left as it is, any consumer with any token could call POST /api/consumers/7/tokens and receive a working token for consumer 7, or mint itself an operator token. An endpoint that issues credentials is the most dangerous one in the system, and it is open by default.
So the first line of the controller closes it:
// app/Http/Controllers/TokenController.php
#[Middleware('ability:consumers:manage')]
class TokenController
{
public function store(
StoreTokenRequest $request,
Consumer $consumer,
): JsonResponse {
$token = $consumer->issueToken(
name: $request->validated('name'),
abilities: $request->validated('abilities'),
);
return response()->json(['data' => [
'id' => $token->accessToken->id,
'name' => $token->accessToken->name,
'token' => $token->plainTextToken,
]], Response::HTTP_CREATED);
}
}
The attribute is on the class, so it covers every action, including ones added later. Only operators get past it.
The plain text is in the response to this one request and nowhere else. The other two actions have nothing secret to show:
// app/Http/Controllers/TokenController.php
public function index(
Consumer $consumer,
): AnonymousResourceCollection {
return TokenResource::collection($consumer->tokens);
}
public function destroy(
Consumer $consumer,
PersonalAccessToken $token,
): Response {
$token->delete();
return response()->noContent();
}
TokenResource lists the name, abilities, last_used_at, and expires_at, and has no way to show the token because the database only holds its hash. destroy is revocation, and because the route is scoped, the token it receives is always one of this consumer’s.
issueToken() is a small method on the model, so that Chapter 4’s two rules (explicit abilities, and an expiry) are applied in one place:
// app/Models/Consumer.php
public function issueToken(
string $name,
array $abilities,
int $days = 365,
): NewAccessToken {
return $this->createToken(
name: $name,
abilities: $abilities,
expiresAt: now()->addDays($days),
);
}
The request decides which abilities may be asked for:
// app/Http/Requests/StoreTokenRequest.php
return [
'name' => ['required', 'string', 'max:100'],
'abilities' => ['required', 'array', 'min:1', 'max:10'],
'abilities.*' => [
'bail', 'string', 'distinct',
Rule::enum(Ability::class),
new HeldByCaller,
],
];
Rule::enum means a typo like license:write is a 422 at the moment the token is issued. The alternative is a token that can’t do what its owner thinks it can, discovered in production. It also keeps * out, because * is not a case of the enum.
HeldByCaller is a rule object like the one in Chapter 2:
// app/Rules/HeldByCaller.php
public function validate(
string $attribute,
mixed $value,
Closure $fail,
): void {
$held = is_string($value)
&& request()->user()?->tokenCan($value);
if (! $held) {
$fail('validation.held_by_caller')->translate();
}
}
A caller may only grant abilities it holds itself. Without the rule, an operator token limited to a few abilities could issue a token with all of them, and the first such token would be a factory for more.