Compare commits

..
Author SHA1 Message Date
89c083aebe Merge pull request '[MTM] Add manual Micropub token generation for non-PKCE clients' (#133) from develop into main
Reviewed-on: #133
2026-09-13 12:32:28 +02:00
568ae78864
Add manual Micropub token generation for non-PKCE clients
iA Writer's IndieAuth client predates PKCE support in the spec, so it
can't complete the normal authorization flow. Add an admin form to
mint a token directly (reusing the existing TokenService), so it can
be pasted into clients that support manual token setup instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 10:40:27 +01:00
12 changed files with 190 additions and 2 deletions

View file

@ -6,7 +6,9 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MicropubToken;
use App\Services\TokenService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TokensController extends Controller
@ -21,6 +23,36 @@ class TokensController extends Controller
return view('admin.tokens.index', compact('tokens'));
}
/**
* Show the form to manually generate a new Micropub token.
*
* This is for clients (e.g. iA Writer) that don't support the IndieAuth
* PKCE flow and instead expect to be given a token directly.
*/
public function create(): View
{
return view('admin.tokens.create');
}
/**
* Manually generate a new Micropub token.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'client_id' => 'required|string',
'scope' => 'required|array|min:1',
]);
$token = resolve(TokenService::class)->getNewToken([
'me' => config('app.url'),
'client_id' => $validated['client_id'],
'scope' => implode(' ', $validated['scope']),
]);
return redirect('/admin/tokens')->with('new_token', $token);
}
/**
* Revoke a Micropub token.
*/

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -134,4 +134,30 @@
.token-list button.revoke:hover {
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));
}
.token-reveal {
margin-block-end: 1em;
padding: 1em 1.2em;
border: 1px solid var(--clr-border);
border-radius: 16px;
background: light-dark(
oklch(96% 0.08 145deg),
oklch(28% 0.08 145deg)
);
input {
width: 100%;
font-family: monospace;
padding: 0.5em 0.7em;
border-radius: 8px;
border: 1px solid var(--clr-border);
}
}
.scope-checkboxes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1em;
}
}

View file

@ -0,0 +1,52 @@
@extends('master')
@section('title')New Token « Admin CP « @stop
@section('content')
<h1>Generate a new token</h1>
<p>Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.</p>
<form action="/admin/tokens" method="post" accept-charset="utf-8" class="admin-form form">
{{ csrf_field() }}
<div>
<label for="client_id">Client</label>
<input
type="text"
name="client_id"
id="client_id"
value="{{ old('client_id') }}"
placeholder="https://ia.net/writer"
required
>
</div>
<div class="scope-checkboxes">
<span>Scope</span>
<label for="scope_create">
<input
type="checkbox"
name="scope[]"
id="scope_create"
value="create"
@checked(in_array('create', old('scope', []), true))
>
create
</label>
<label for="scope_update">
<input
type="checkbox"
name="scope[]"
id="scope_update"
value="update"
@checked(in_array('update', old('scope', []), true))
>
update
</label>
</div>
<div>
<button type="submit" name="save">Generate token</button>
</div>
</form>
@stop

View file

@ -4,6 +4,15 @@
@section('content')
<h1>Micropub Tokens</h1>
<p><a href="/admin/tokens/create">Generate new token</a></p>
@if(session('new_token'))
<div class="token-reveal">
<p>Here's your new token. <strong>Copy it now</strong> — it won't be shown again.</p>
<input type="text" readonly value="{{ session('new_token') }}" onclick="this.select()">
</div>
@endif
@if($tokens->isEmpty())
<p>No tokens have been issued.</p>
@else

View file

@ -157,6 +157,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Micropub Tokens
Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']);
Route::get('/create', [TokensController::class, 'create']);
Route::post('/', [TokensController::class, 'store']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
});

View file

@ -37,6 +37,73 @@ class TokensTest extends TestCase
$response->assertSeeText($token->client_id);
}
#[Test]
public function create_requires_authentication(): void
{
$response = $this->get('/admin/tokens/create');
$response->assertRedirect();
}
#[Test]
public function create_shows_form(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->get('/admin/tokens/create');
$response->assertOk();
$response->assertSee('name="client_id"', false);
}
#[Test]
public function store_requires_authentication(): void
{
$response = $this->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create'],
]);
$response->assertRedirect();
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function store_creates_a_new_token_and_redirects_with_it_flashed(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => ['create', 'update'],
]);
$response->assertRedirect('/admin/tokens');
$response->assertSessionHas('new_token');
$this->assertDatabaseHas('micropub_tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => 'create update',
'me' => config('app.url'),
]);
$token = $response->getSession()->get('new_token');
$this->assertNotNull(MicropubToken::findActive($token));
}
#[Test]
public function store_requires_at_least_one_scope(): void
{
$user = User::factory()->make();
$response = $this->actingAs($user)->post('/admin/tokens', [
'client_id' => 'https://ia.net/writer',
'scope' => [],
]);
$response->assertSessionHasErrors('scope');
$this->assertDatabaseCount('micropub_tokens', 0);
}
#[Test]
public function revoke_requires_authentication(): void
{