Mobile auth: native login + register via central identity API.
Deploy Ladill Mini / deploy (push) Successful in 31s

Login no longer uses the OAuth password grant (which wasn't enabled, the cause
of login failures). Both login and the new register endpoint proxy to
auth.ladill.com /api/identity/auth/* (shared IDENTITY_API_KEY_MINI), provision
the local mirror, and issue a Sanctum token — one Ladill identity across web
and app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-10 23:04:04 +00:00
co-authored by Claude Opus 4.8
parent 307248749b
commit cd56771b59
3 changed files with 105 additions and 45 deletions
+93 -43
View File
@@ -5,19 +5,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\QrTeamMember; use App\Models\QrTeamMember;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
/** /**
* Token login for the Ladill Mini mobile app. * Native token auth for the Ladill Mini mobile app.
* *
* Mirrors the web "Sign in with Ladill" flow (SsoLoginController) but uses the * Login and registration proxy to the central Ladill identity API
* OAuth Resource Owner Password grant so the native app can collect the * (auth.ladill.com /api/identity/auth/*, gated by the shared first-party
* credentials directly. On success we provision the same thin local user * service key) so app accounts are the same single identity used by the website
* mirror keyed by the OIDC `sub` and hand back a Sanctum personal access token * and every other Ladill app. We then provision the thin local user mirror
* the app stores for `auth:sanctum` requests. * (keyed by the OIDC `sub`, exactly like the web SSO callback) and hand back a
* Sanctum token the app uses for `auth:sanctum` requests.
*/ */
class AuthController extends Controller class AuthController extends Controller
{ {
@@ -29,48 +31,68 @@ class AuthController extends Controller
'device_name' => ['nullable', 'string', 'max:120'], 'device_name' => ['nullable', 'string', 'max:120'],
]); ]);
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); $response = $this->identity()->post('/api/identity/auth/login', [
'email' => $credentials['email'],
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'password',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'username' => $credentials['email'],
'password' => $credentials['password'], 'password' => $credentials['password'],
'scope' => 'openid profile email',
]); ]);
if ($tokenRes->failed()) { if ($response->status() === 422) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => ['The email or password is incorrect.'], 'email' => ['The email or password is incorrect.'],
]); ]);
} }
$user = $this->provisionFromAccessToken($issuer, (string) $tokenRes->json('access_token')); $user = $this->provisionFromResponse($response);
if (! $user) { return $this->issueToken($user, $credentials['device_name'] ?? null);
throw ValidationException::withMessages([ }
'email' => ['We could not verify your Ladill account. Please try again.'],
]); public function register(Request $request): JsonResponse
{
// Mirror the web signup form; the monolith validates authoritatively, but
// we pre-validate so the app gets fast, field-level feedback.
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'company' => ['nullable', 'string', 'max:255'],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:255'],
'state' => ['required', 'string', 'max:255'],
'country' => ['required', 'string', 'size:2'],
'zipcode' => ['required', 'string', 'max:32'],
'phone_cc' => ['required', 'string', 'max:8'],
'phone' => ['required', 'string', 'max:32'],
'mobile_cc' => ['nullable', 'string', 'max:8'],
'mobile' => ['nullable', 'string', 'max:32'],
'terms' => ['accepted'],
'device_name' => ['nullable', 'string', 'max:120'],
]);
$response = $this->identity()->post('/api/identity/auth/register', array_merge(
$request->only([
'name', 'email', 'password', 'password_confirmation', 'company',
'address', 'city', 'state', 'country', 'zipcode',
'phone_cc', 'phone', 'mobile_cc', 'mobile',
]),
['terms' => $request->boolean('terms')],
));
// Surface the monolith's validation errors (e.g. email already taken).
if ($response->status() === 422) {
throw ValidationException::withMessages(
$response->json('errors') ?: ['email' => [$response->json('message') ?: 'Registration failed.']],
);
} }
QrTeamMember::linkPendingInvitesFor($user); $user = $this->provisionFromResponse($response);
$deviceName = $credentials['device_name'] ?? 'Ladill Mini Android'; return $this->issueToken($user, $data['device_name'] ?? null);
$token = $user->createToken($deviceName, ['mini:read', 'mini:write']);
return response()->json([
'data' => [
'token' => $token->plainTextToken,
'user' => $this->presentUser($user),
],
]);
} }
public function logout(Request $request): JsonResponse public function logout(Request $request): JsonResponse
{ {
$token = $request->user()?->currentAccessToken(); $request->user()?->currentAccessToken()?->delete();
$token?->delete();
return response()->json(['data' => ['message' => 'Signed out.']]); return response()->json(['data' => ['message' => 'Signed out.']]);
} }
@@ -80,30 +102,58 @@ class AuthController extends Controller
return response()->json(['data' => $this->presentUser($request->user())]); return response()->json(['data' => $this->presentUser($request->user())]);
} }
private function provisionFromAccessToken(string $issuer, string $accessToken): ?User private function identity(): \Illuminate\Http\Client\PendingRequest
{ {
if ($accessToken === '') { return Http::baseUrl(rtrim((string) config('services.ladill_identity.url'), '/'))
return null; ->withToken((string) config('services.ladill_identity.key'))
->acceptJson()
->asJson();
}
/** Upsert the local mirror from the identity API's OIDC claims. */
private function provisionFromResponse(HttpResponse $response): User
{
if ($response->failed()) {
throw ValidationException::withMessages([
'email' => ['We could not reach Ladill sign-in. Please try again.'],
]);
} }
$claims = Http::withToken($accessToken)->acceptJson()->get($issuer.'/oauth/userinfo'); $claims = (array) $response->json('data.user', []);
$sub = (string) ($claims['sub'] ?? '');
if ($claims->failed() || ! $claims->json('sub')) { if ($sub === '') {
return null; throw ValidationException::withMessages([
'email' => ['We could not verify your Ladill account. Please try again.'],
]);
} }
$email = (string) ($claims->json('email') ?: ''); $email = (string) ($claims['email'] ?? '');
return User::updateOrCreate( return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')], ['public_id' => $sub],
[ [
'name' => $claims->json('name'), 'name' => $claims['name'] ?? null,
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com', 'email' => $email !== '' ? $email : $sub.'@users.ladill.com',
'avatar_url' => $claims->json('picture'), 'avatar_url' => $claims['picture'] ?? null,
], ],
); );
} }
private function issueToken(User $user, ?string $deviceName): JsonResponse
{
QrTeamMember::linkPendingInvitesFor($user);
$token = $user->createToken($deviceName ?: 'Ladill Mini Android', ['mini:read', 'mini:write']);
return response()->json([
'data' => [
'token' => $token->plainTextToken,
'user' => $this->presentUser($user),
],
]);
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function presentUser(User $user): array private function presentUser(User $user): array
{ {
+8
View File
@@ -26,6 +26,14 @@ return [
'redirect' => rtrim((string) env('APP_URL', 'https://mini.ladill.com'), '/').'/sso/callback', 'redirect' => rtrim((string) env('APP_URL', 'https://mini.ladill.com'), '/').'/sso/callback',
], ],
// Central Ladill identity API (auth.ladill.com /api/identity/auth/*). The
// mobile app's native login/register proxy through here, gated by the
// shared first-party service key (config/identity.php on the monolith).
'ladill_identity' => [
'url' => 'https://'.config('app.auth_domain'),
'key' => env('IDENTITY_API_KEY_MINI'),
],
'ladill_webmail' => [ 'ladill_webmail' => [
'url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'), 'url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'),
], ],
+4 -2
View File
@@ -10,8 +10,10 @@ use App\Http\Controllers\Api\QrCodeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
// Mobile token login (Ladill Mini Android). Public — issues a Sanctum token. // Mobile token auth (Ladill Mini Android). Public — proxies to the central
Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); // Ladill identity API and issues a Sanctum token.
Route::post('/auth/login', [AuthController::class, 'login'])->middleware('throttle:10,1')->name('api.auth.login');
Route::post('/auth/register', [AuthController::class, 'register'])->middleware('throttle:10,1')->name('api.auth.register');
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () { Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout');