diff --git a/CHANGELOG.md b/CHANGELOG.md index c146354..6877731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Configurable route prefix** — new `route_prefix` config key (`NEEV_ROUTE_PREFIX`, default `neev`) namespaces every machine-facing route the package registers: the API namespace, OAuth redirect/callback, tenant SSO, and `/csrf-cookie`. Blade UI pages (`/login`, `/account/...`) stay at the root. Route names are unchanged. The MFA-token route gate in `NeevAPIMiddleware` now follows the prefix (previously hardcoded — customised route files silently broke MFA step-up) + +### Changed +- **BREAKING: OAuth and tenant-SSO routes moved under the route prefix** — `/oauth/{service}[/callback]` → `/neev/oauth/{service}[/callback]`, `/sso/redirect|callback` → `/neev/sso/...`, and `/api/tenant/auth` → `/neev/tenant/auth`. **Update the redirect URIs registered with your identity providers** (Entra/Google/Okta app registrations, OAuth apps) - **SPA cookie mode — phase 1 (plumbing)** — same-origin SPAs can now authenticate via an HttpOnly cookie instead of JS-stored bearer tokens: - `EnsureSpaRequestsAreStateful` middleware (in the `neev:api` and `neev:login` groups): for requests from a configured stateful origin, validates a signed double-submit CSRF token on state-changing methods (419 on failure) and promotes the auth cookie to an `Authorization: Bearer` header; a no-op for everything else, so existing bearer callers are untouched - `GET /neev/csrf-cookie` issues the CSRF cookie; the token is HMAC-signed to the app key (defeats subdomain cookie injection, no server-side state) diff --git a/README.md b/README.md index 060bc61..f191926 100644 --- a/README.md +++ b/README.md @@ -209,14 +209,14 @@ Enable in configuration: ``` Redirect URLs: -- `GET /oauth/{service}` - Redirect to provider -- `GET /oauth/{service}/callback` - Handle callback +- `GET /neev/oauth/{service}` - Redirect to provider +- `GET /neev/oauth/{service}/callback` - Handle callback --- ## API Reference -All API routes are prefixed with `/neev`. Include the Bearer token for authenticated endpoints. +All API routes are prefixed with `/neev` — the prefix is configurable via `route_prefix` in `config/neev.php` (env `NEEV_ROUTE_PREFIX`). Include the Bearer token for authenticated endpoints. ### Authentication Endpoints @@ -349,10 +349,10 @@ All API routes are prefixed with `/neev`. Include the Bearer token for authentic | POST | `/update-password` | `user-password.update` | Process reset | | GET | `/otp/mfa/{method}` | `otp.mfa.create` | MFA verification | | POST | `/otp/mfa` | `otp.mfa.store` | Verify MFA code | -| GET | `/oauth/{service}` | `oauth.redirect` | OAuth redirect | -| GET | `/oauth/{service}/callback` | `oauth.callback` | OAuth callback | -| GET | `/sso/redirect` | `sso.redirect` | Tenant SSO redirect | -| GET | `/sso/callback` | `sso.callback` | Tenant SSO callback | +| GET | `/neev/oauth/{service}` | `oauth.redirect` | OAuth redirect | +| GET | `/neev/oauth/{service}/callback` | `oauth.callback` | OAuth callback | +| GET | `/neev/sso/redirect` | `sso.redirect` | Tenant SSO redirect | +| GET | `/neev/sso/callback` | `sso.callback` | Tenant SSO callback | ### Authenticated Routes (neev:web middleware) @@ -533,7 +533,7 @@ php artisan neev:auth:show # Show current auth settings ### Get Tenant Auth Config ```bash -curl -X GET https://acme.yourapp.com/api/tenant/auth +curl -X GET https://acme.yourapp.com/neev/tenant/auth ``` Response: @@ -542,7 +542,7 @@ Response: "auth_method": "sso", "sso_enabled": true, "sso_provider": "entra", - "sso_redirect_url": "https://acme.yourapp.com/sso/redirect" + "sso_redirect_url": "https://acme.yourapp.com/neev/sso/redirect" } ``` diff --git a/UPGRADING.md b/UPGRADING.md index 58cda28..7982b82 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -13,6 +13,26 @@ changes see [CHANGELOG.md](./CHANGELOG.md). ## 0.4.5 → Unreleased +**OAuth/SSO routes moved under the route prefix (action required for +identity providers).** +All machine-facing routes now live under the configurable +`route_prefix` (default `neev`): + +| Old path | New path | +|---|---| +| `/oauth/{service}` and `/oauth/{service}/callback` (web) | `/neev/oauth/{service}[/callback]` | +| `/sso/redirect`, `/sso/callback` | `/neev/sso/redirect`, `/neev/sso/callback` | +| `/api/tenant/auth` | `/neev/tenant/auth` | + +- **Update the redirect URIs registered at your identity providers** + (Microsoft Entra, Google, Okta app registrations and OAuth apps) to + the new callback URLs. +- Apps that published `routes/neev.php` keep their published copy — + re-publish or re-apply your customisations to pick up the prefix. +- To rename the namespace (e.g. `/auth/...`), set + `NEEV_ROUTE_PREFIX=auth`. Route *names* (`neev.*` etc.) are + unchanged either way. + **Authenticator MFA setup now requires verification (behaviour change).** Adding an authenticator creates it in `pending` status; the user must submit a valid TOTP (API: `POST /neev/mfa/setup/verify`; web: the diff --git a/config/neev.php b/config/neev.php index 3bf1dcb..c94185b 100644 --- a/config/neev.php +++ b/config/neev.php @@ -17,6 +17,24 @@ // Team sub-grouping. Optional in both tenant and non-tenant modes. 'team' => false, + /* + |-------------------------------------------------------------------------- + | Routes + |-------------------------------------------------------------------------- + | + | URL prefix for every machine-facing route the package registers: the + | API namespace, OAuth redirect/callback, and tenant SSO endpoints + | (e.g. 'auth' gives /auth/login, /auth/oauth/{service}/callback, + | /auth/sso/callback). Blade UI pages (/login, /account/...) stay at + | the root — they are end-user URLs, not part of the API namespace. + | + | Changing this also changes the OAuth/SSO callback URLs registered + | with your identity providers — update those app registrations too. + | Route NAMES are unaffected. + | + */ + 'route_prefix' => env('NEEV_ROUTE_PREFIX', 'neev'), + /* |-------------------------------------------------------------------------- | Authentication diff --git a/docs/api-reference.md b/docs/api-reference.md index 1bc7146..2b4d0cc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,6 @@ # API Reference -Complete reference for all Neev API endpoints. All API routes are prefixed with `/neev`. +Complete reference for all Neev API endpoints. All API routes are prefixed with the configurable route prefix — `route_prefix` in `config/neev.php` (env `NEEV_ROUTE_PREFIX`), default `neev`. This documentation uses the default `/neev` prefix throughout. --- diff --git a/docs/authentication.md b/docs/authentication.md index bdd9ee1..0989ac9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -363,7 +363,7 @@ Authenticate via third-party providers. ```env GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret -GOOGLE_REDIRECT_URI="${APP_URL}/oauth/google/callback" +GOOGLE_REDIRECT_URI="${APP_URL}/neev/oauth/google/callback" ``` ### Security Note @@ -382,8 +382,10 @@ OAuth and Social login bypasses Neev's MFA requirements and password policies. U ### URLs -- **Redirect:** `GET /oauth/{provider}` -- **Callback:** `GET /oauth/{provider}/callback` +- **Redirect:** `GET /neev/oauth/{provider}` +- **Callback:** `GET /neev/oauth/{provider}/callback` + +The `/neev` prefix is configurable via `route_prefix` in `config/neev.php` (env `NEEV_ROUTE_PREFIX`). Changing it also changes the callback URLs registered with your OAuth providers. --- @@ -433,7 +435,7 @@ $tenant->authSettings()->create([ ### API Endpoint ```http -GET /api/tenant/auth +GET /neev/tenant/auth ``` Returns tenant auth configuration: @@ -443,7 +445,7 @@ Returns tenant auth configuration: "auth_method": "sso", "sso_enabled": true, "sso_provider": "entra", - "sso_redirect_url": "https://acme.yourapp.com/sso/redirect" + "sso_redirect_url": "https://acme.yourapp.com/neev/sso/redirect" } ``` diff --git a/docs/configuration.md b/docs/configuration.md index 69883e1..e7758ea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,22 @@ The `neev:install` wizard asks exactly these two questions and sets the flags fo --- +## Routes + +### Route Prefix + +```php +'route_prefix' => env('NEEV_ROUTE_PREFIX', 'neev'), +``` + +URL prefix for every machine-facing route the package registers: the API namespace (including `/csrf-cookie`), the OAuth redirect/callback routes, and the tenant SSO endpoints. For example, setting it to `auth` gives `/auth/login`, `/auth/oauth/{service}/callback`, and `/auth/sso/callback`. + +Blade UI pages (`/login`, `/register`, `/account/...`) stay at the root — they are end-user URLs, not part of the API namespace. + +> **Warning:** Changing the prefix also changes the OAuth/SSO callback URLs registered with your identity providers — update those app registrations too. Route names are unaffected. + +--- + ## Authentication ### Username Support @@ -352,6 +368,9 @@ return [ 'tenant' => false, 'team' => true, + // Routes + 'route_prefix' => env('NEEV_ROUTE_PREFIX', 'neev'), + // Authentication 'support_username' => false, 'oauth' => [ @@ -436,6 +455,7 @@ return [ | Variable | Description | Default | |----------|-------------|---------| +| `NEEV_ROUTE_PREFIX` | Prefix for machine-facing routes (API, OAuth, SSO, csrf-cookie) | `neev` | | `NEEV_JWT_SECRET` | Secret for signing MFA JWTs | Falls back to `APP_KEY` | | `MAXMIND_EDITION` | GeoIP database edition | `GeoLite2-City` | | `MAXMIND_LICENSE_KEY` | MaxMind license key | - | diff --git a/docs/design-principles.md b/docs/design-principles.md index 54c304d..5663060 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -120,6 +120,7 @@ Worked examples of the layers in action, for calibration: |---|---|---| | MFA pending → active requires OTP proof in shipped flows; no config toggle | 1 | A toggle would be a footgun; the flow is what neev is responsible for | | `MultiFactorAuth::activate()` exists for programmatic activation | 4 | Legitimate bypass, explicit in app code, keeps event + preferred invariants | +| Route prefix is a config value (`route_prefix`, default `neev`), not hardcoded to `auth` | 2 | User-visible string real apps differ on (Sanctum/Fortify precedent); defaulting to `auth` would be collision-prone and breaking, and "publish the routes file" was a trap — the MFA route gate hardcoded the old prefix | | `login_throttle` delays are configurable numbers | 2 | Apps differ on tolerance; it's a value, not a branch | | Email verification enforcement is a middleware alias, not automatic | 3 | Which routes require it is product policy; the mechanism is fully shipped | | Push notifications rejected from the package (PR #25) | — | Fails the litmus test; events give the app everything it needs | diff --git a/docs/installation.md b/docs/installation.md index b049674..4f06456 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -290,12 +290,14 @@ Update configuration: ## OAuth Provider Setup +The callback URLs below use the default route prefix (`route_prefix` in `config/neev.php`, env `NEEV_ROUTE_PREFIX`, default `neev`). If you change the prefix, update the redirect URIs registered with each provider accordingly. + ### Google 1. Create a project in [Google Cloud Console](https://console.cloud.google.com/) 2. Enable Google+ API 3. Create OAuth 2.0 credentials -4. Add redirect URI: `https://yourapp.com/oauth/google/callback` +4. Add redirect URI: `https://yourapp.com/neev/oauth/google/callback` ```php // config/services.php @@ -309,13 +311,13 @@ Update configuration: ```env GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret -GOOGLE_REDIRECT_URI="${APP_URL}/oauth/google/callback" +GOOGLE_REDIRECT_URI="${APP_URL}/neev/oauth/google/callback" ``` ### GitHub 1. Create an OAuth App in [GitHub Developer Settings](https://github.com/settings/developers) -2. Add callback URL: `https://yourapp.com/oauth/github/callback` +2. Add callback URL: `https://yourapp.com/neev/oauth/github/callback` ```php // config/services.php @@ -329,7 +331,7 @@ GOOGLE_REDIRECT_URI="${APP_URL}/oauth/google/callback" ### Microsoft 1. Register an app in [Azure Portal](https://portal.azure.com/) -2. Add redirect URI: `https://yourapp.com/oauth/microsoft/callback` +2. Add redirect URI: `https://yourapp.com/neev/oauth/microsoft/callback` ```php // config/services.php diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md index f09b614..0896802 100644 --- a/docs/multi-tenancy.md +++ b/docs/multi-tenancy.md @@ -497,7 +497,7 @@ $tenant->authSettings()->create([ Azure App Registration: 1. Go to Azure Portal > App Registrations 2. Create new registration -3. Add redirect URI: `https://tenant.yourapp.com/sso/callback` +3. Add redirect URI: `https://tenant.yourapp.com/neev/sso/callback` 4. Generate client secret 5. Note the Application (client) ID and Directory (tenant) ID @@ -532,7 +532,7 @@ $tenant->authSettings()->create([ ### 1. Get Tenant Auth Config ```bash -curl -X GET https://acme.yourapp.com/api/tenant/auth +curl -X GET https://acme.yourapp.com/neev/tenant/auth ``` **Response:** @@ -542,14 +542,14 @@ curl -X GET https://acme.yourapp.com/api/tenant/auth "auth_method": "sso", "sso_enabled": true, "sso_provider": "entra", - "sso_redirect_url": "https://acme.yourapp.com/sso/redirect" + "sso_redirect_url": "https://acme.yourapp.com/neev/sso/redirect" } ``` ### 2. Redirect to SSO ```http -GET /sso/redirect?email=user@acme.com +GET /neev/sso/redirect?email=user@acme.com ``` User is redirected to the identity provider. @@ -559,7 +559,7 @@ User is redirected to the identity provider. After authentication, the user is redirected to: ```http -GET /sso/callback?code=auth-code&state=... +GET /neev/sso/callback?code=auth-code&state=... ``` Neev: @@ -573,7 +573,7 @@ Neev: For single-page applications, pass a `redirect_uri`: ```http -GET /sso/redirect?redirect_uri=https://acme.yourapp.com/app +GET /neev/sso/redirect?redirect_uri=https://acme.yourapp.com/app ``` After SSO, user is redirected with the token in the URL fragment (not query parameter) to prevent server-side logging: @@ -680,14 +680,16 @@ $ssoManager->ensureMembership($user, $tenant); | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/tenant/auth` | Get tenant auth config | +| GET | `/neev/tenant/auth` | Get tenant auth config | ### SSO | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/sso/redirect` | Initiate SSO flow | -| GET | `/sso/callback` | Handle SSO callback | +| GET | `/neev/sso/redirect` | Initiate SSO flow | +| GET | `/neev/sso/callback` | Handle SSO callback | + +> The `/neev` prefix on these endpoints is configurable via `route_prefix` in `config/neev.php` (env `NEEV_ROUTE_PREFIX`). --- diff --git a/docs/web-routes.md b/docs/web-routes.md index 0a75fb1..ed66759 100644 --- a/docs/web-routes.md +++ b/docs/web-routes.md @@ -79,10 +79,12 @@ These routes are accessible without authentication. ### OAuth / Social Login +These routes live under the configurable route prefix (`route_prefix` in `config/neev.php`, env `NEEV_ROUTE_PREFIX`, default `neev`). The paths below use the default prefix. + | Method | Route | Name | Description | |--------|-------|------|-------------| -| GET | `/oauth/{service}` | `oauth.redirect` | Redirect to OAuth provider | -| GET | `/oauth/{service}/callback` | `oauth.callback` | Handle OAuth callback | +| GET | `/neev/oauth/{service}` | `oauth.redirect` | Redirect to OAuth provider | +| GET | `/neev/oauth/{service}/callback` | `oauth.callback` | Handle OAuth callback | **URL Parameters:** - `service` - OAuth provider (`google`, `github`, `microsoft`, `apple`) @@ -91,10 +93,12 @@ These routes are accessible without authentication. ### Tenant SSO +These routes also live under the configurable route prefix. + | Method | Route | Name | Description | |--------|-------|------|-------------| -| GET | `/sso/redirect` | `sso.redirect` | Redirect to tenant's SSO provider | -| GET | `/sso/callback` | `sso.callback` | Handle SSO callback | +| GET | `/neev/sso/redirect` | `sso.redirect` | Redirect to tenant's SSO provider | +| GET | `/neev/sso/callback` | `sso.callback` | Handle SSO callback | --- diff --git a/routes/neev.php b/routes/neev.php index 5d6a40b..33d40c6 100644 --- a/routes/neev.php +++ b/routes/neev.php @@ -41,11 +41,14 @@ Route::get('/update-password/{id}/{hash}', [UserAuthController::class, 'updatePasswordCreate']) ->name('reset.request'); - //OAuth - Route::get('/oauth/{service}', [OAuthController::class, 'redirect']) - ->name('oauth.redirect'); - Route::get('/oauth/{service}/callback', [OAuthController::class, 'callback']) - ->name('oauth.callback'); + //OAuth (web) — under the machine-facing route prefix; these URLs are + // registered as redirect URIs at the identity providers. + Route::prefix(config('neev.route_prefix', 'neev'))->group(function () { + Route::get('/oauth/{service}', [OAuthController::class, 'redirect']) + ->name('oauth.redirect'); + Route::get('/oauth/{service}/callback', [OAuthController::class, 'callback']) + ->name('oauth.callback'); + }); Route::get('/email/verify/{id}/{hash}', [UserAuthController::class, 'emailVerifyStore']) ->name('verification.verify'); @@ -193,7 +196,7 @@ }); //APIs -Route::prefix('/neev')->middleware(TenantMiddleware::class)->group(function () { +Route::prefix(config('neev.route_prefix', 'neev'))->middleware(TenantMiddleware::class)->group(function () { Route::get('/csrf-cookie', [CsrfCookieController::class, 'show']) ->middleware('throttle:60,1') ->name('neev.csrf-cookie'); diff --git a/routes/sso.php b/routes/sso.php index e8937c8..c9f7cfe 100644 --- a/routes/sso.php +++ b/routes/sso.php @@ -14,14 +14,17 @@ | */ -// Web routes for SSO flow -Route::middleware('web')->group(function () { - Route::get('/sso/redirect', [TenantSSOController::class, 'redirect']) - ->name('sso.redirect'); - Route::get('/sso/callback', [TenantSSOController::class, 'callback']) - ->name('sso.callback'); -}); +Route::prefix(config('neev.route_prefix', 'neev'))->group(function () { + // Web routes for SSO flow — these URLs are registered as redirect + // URIs at the identity providers. + Route::middleware('web')->group(function () { + Route::get('/sso/redirect', [TenantSSOController::class, 'redirect']) + ->name('sso.redirect'); + Route::get('/sso/callback', [TenantSSOController::class, 'callback']) + ->name('sso.callback'); + }); -// API route to get tenant auth configuration (public, no auth required) -Route::get('/api/tenant/auth', [TenantSSOController::class, 'authConfig']) - ->name('api.tenant.auth'); + // API route to get tenant auth configuration (public, no auth required) + Route::get('/tenant/auth', [TenantSSOController::class, 'authConfig']) + ->name('api.tenant.auth'); +}); diff --git a/src/Http/Controllers/Auth/OAuthApiController.php b/src/Http/Controllers/Auth/OAuthApiController.php index ff7e2ef..6101a6a 100644 --- a/src/Http/Controllers/Auth/OAuthApiController.php +++ b/src/Http/Controllers/Auth/OAuthApiController.php @@ -32,7 +32,7 @@ public function redirectUrl(Request $request, string $service) $params['login_hint'] = $request->email; } - $redirectUrl = config('app.url') . '/oauth/' . $service . '/callback'; + $redirectUrl = config('app.url') . '/' . trim(config('neev.route_prefix', 'neev'), '/') . '/oauth/' . $service . '/callback'; /** @var \Laravel\Socialite\Two\AbstractProvider $driver */ $driver = Socialite::driver($service); @@ -63,7 +63,7 @@ public function callback(Request $request, string $service, GeoIP $geoIP) } try { - $redirectUrl = config('app.url') . '/oauth/' . $service . '/callback'; + $redirectUrl = config('app.url') . '/' . trim(config('neev.route_prefix', 'neev'), '/') . '/oauth/' . $service . '/callback'; /** @var \Laravel\Socialite\Two\AbstractProvider $driver */ $driver = Socialite::driver($service); diff --git a/src/Http/Middleware/NeevAPIMiddleware.php b/src/Http/Middleware/NeevAPIMiddleware.php index 4c17e70..e475f8a 100644 --- a/src/Http/Middleware/NeevAPIMiddleware.php +++ b/src/Http/Middleware/NeevAPIMiddleware.php @@ -31,7 +31,9 @@ public function handle(Request $request, Closure $next): Response [$id, $token] = explode('|', $token, 2); $accessToken = AccessToken::with('attempt')->find($id); - if (!$accessToken || !Hash::check($token, $accessToken->token) || ($accessToken->token_type == AccessToken::mfa_token && !$request->is(['neev/mfa/otp/verify', 'neev/mfa']))) { + $prefix = trim(config('neev.route_prefix', 'neev'), '/'); + + if (!$accessToken || !Hash::check($token, $accessToken->token) || ($accessToken->token_type == AccessToken::mfa_token && !$request->is(["{$prefix}/mfa/otp/verify", "{$prefix}/mfa"]))) { return response()->json([ 'message' => 'Invalid or expired token' ], 401); diff --git a/tests/Feature/Auth/CustomRoutePrefixTest.php b/tests/Feature/Auth/CustomRoutePrefixTest.php new file mode 100644 index 0000000..1d244eb --- /dev/null +++ b/tests/Feature/Auth/CustomRoutePrefixTest.php @@ -0,0 +1,77 @@ +set('neev.route_prefix', 'auth'); + $app['config']->set('neev.tenant', true); // load SSO routes too + } + + public function test_api_routes_live_under_the_custom_prefix(): void + { + $user = User::factory()->create(['password' => 'password123']); + config(['neev.password' => ['required']]); + + $this->postJson('/auth/login', ['email' => $user->email, 'password' => 'password123']) + ->assertOk() + ->assertJsonPath('auth_state', 'authenticated'); + + $this->postJson('/neev/login', ['email' => $user->email, 'password' => 'password123']) + ->assertNotFound(); + } + + public function test_authenticated_routes_work_under_the_custom_prefix(): void + { + $user = User::factory()->create(); + $token = $user->createLoginToken(1440)->plainTextToken; + + $this->withHeader('Authorization', 'Bearer ' . $token) + ->getJson('/auth/users') + ->assertOk(); + } + + public function test_mfa_token_route_gate_follows_the_prefix(): void + { + // An MFA-type token may only reach the MFA verification routes. + // The gate matches paths, so it must track the configured prefix. + $user = User::factory()->create(); + $mfaToken = $user->accessTokens()->create([ + 'name' => 'mfa', + 'token' => $plain = \Illuminate\Support\Str::random(40), + 'token_type' => \Ssntpl\Neev\Models\AccessToken::mfa_token, + ]); + $bearer = $mfaToken->id . '|' . $plain; + + // Blocked from ordinary API routes under the custom prefix. + $this->withHeader('Authorization', 'Bearer ' . $bearer) + ->getJson('/auth/users') + ->assertUnauthorized(); + + // Allowed on the MFA route under the custom prefix. + $this->withHeader('Authorization', 'Bearer ' . $bearer) + ->getJson('/auth/mfa') + ->assertOk(); + } + + public function test_sso_and_csrf_routes_live_under_the_custom_prefix(): void + { + $this->get('/auth/csrf-cookie')->assertNoContent(); + $this->getJson('/auth/tenant/auth')->assertOk(); + $this->getJson('/neev/tenant/auth')->assertNotFound(); + } +} diff --git a/tests/Feature/Auth/OAuthTest.php b/tests/Feature/Auth/OAuthTest.php index ebe41ce..ec8cfd5 100644 --- a/tests/Feature/Auth/OAuthTest.php +++ b/tests/Feature/Auth/OAuthTest.php @@ -83,7 +83,7 @@ public function test_redirect_to_configured_oauth_provider(): void { $this->mockSocialiteRedirect(); - $response = $this->get('/oauth/google'); + $response = $this->get('/neev/oauth/google'); $response->assertRedirect(); $this->assertStringContainsString('accounts.google.com', $response->headers->get('Location')); @@ -103,14 +103,14 @@ public function test_redirect_passes_login_hint_when_email_provided(): void ->with('google') ->andReturn($provider); - $response = $this->get('/oauth/google?email=user@example.com'); + $response = $this->get('/neev/oauth/google?email=user@example.com'); $response->assertRedirect(); } public function test_redirect_returns_404_for_unconfigured_service(): void { - $response = $this->get('/oauth/github'); + $response = $this->get('/neev/oauth/github'); $response->assertStatus(404); } @@ -125,7 +125,7 @@ public function test_callback_logs_in_existing_user(): void $this->mockSocialiteUser($user->email); - $response = $this->get('/oauth/google/callback?code=test-auth-code'); + $response = $this->get('/neev/oauth/google/callback?code=test-auth-code'); $response->assertRedirect('/dashboard'); // No stateful host configured: no SPA cookie. @@ -143,7 +143,7 @@ public function test_callback_on_stateful_host_also_issues_spa_cookie(): void $user = User::factory()->create(); $this->mockSocialiteUser($user->email); - $response = $this->get('/oauth/google/callback?code=test-auth-code'); + $response = $this->get('/neev/oauth/google/callback?code=test-auth-code'); $response->assertRedirect('/dashboard'); @@ -167,7 +167,7 @@ public function test_callback_creates_new_user_when_email_not_found(): void $countBefore = User::count(); - $this->get('/oauth/google/callback?code=test-auth-code'); + $this->get('/neev/oauth/google/callback?code=test-auth-code'); // User should be created regardless of login outcome $this->assertEquals($countBefore + 1, User::count()); @@ -178,7 +178,7 @@ public function test_callback_creates_new_user_when_email_not_found(): void public function test_callback_redirects_to_login_when_no_code(): void { - $response = $this->get('/oauth/google/callback'); + $response = $this->get('/neev/oauth/google/callback'); $response->assertRedirect(route('login')); } @@ -189,7 +189,7 @@ public function test_callback_redirects_to_login_for_unverified_email(): void $this->mockSocialiteUser($user->email); - $response = $this->get('/oauth/google/callback?code=test-auth-code'); + $response = $this->get('/neev/oauth/google/callback?code=test-auth-code'); $response->assertRedirect(route('login')); } @@ -200,7 +200,7 @@ public function test_callback_creates_team_when_teams_enabled(): void $this->mockSocialiteUser('teamuser@example.com', 'Team User'); - $this->get('/oauth/google/callback?code=test-auth-code'); + $this->get('/neev/oauth/google/callback?code=test-auth-code'); $user = User::where('email', 'teamuser@example.com')->first(); $this->assertNotNull($user); @@ -209,7 +209,7 @@ public function test_callback_creates_team_when_teams_enabled(): void public function test_callback_returns_404_for_unconfigured_service(): void { - $response = $this->get('/oauth/github/callback?code=test-code'); + $response = $this->get('/neev/oauth/github/callback?code=test-code'); $response->assertStatus(404); } @@ -238,7 +238,7 @@ public function test_callback_with_domain_federation_skips_team_creation_for_ver $this->mockSocialiteUser('newuser@verified-corp.com', 'Corp User'); - $this->get('/oauth/google/callback?code=test-auth-code'); + $this->get('/neev/oauth/google/callback?code=test-auth-code'); $user = User::where('email', 'newuser@verified-corp.com')->first(); $this->assertNotNull($user); @@ -254,7 +254,7 @@ public function test_callback_with_domain_federation_creates_team_for_unverified $this->mockSocialiteUser('newuser@unknown-domain.com', 'Indie User'); - $this->get('/oauth/google/callback?code=test-auth-code'); + $this->get('/neev/oauth/google/callback?code=test-auth-code'); $user = User::where('email', 'newuser@unknown-domain.com')->first(); $this->assertNotNull($user); diff --git a/tests/Feature/Auth/TenantSSOAdditionalTest.php b/tests/Feature/Auth/TenantSSOAdditionalTest.php index a1f17a8..d096f6a 100644 --- a/tests/Feature/Auth/TenantSSOAdditionalTest.php +++ b/tests/Feature/Auth/TenantSSOAdditionalTest.php @@ -51,7 +51,7 @@ public function test_auth_config_returns_sso_disabled_when_tenant_has_sso_config $this->setCurrentTenant($team); - $response = $this->getJson('/api/tenant/auth'); + $response = $this->getJson('/neev/tenant/auth'); $response->assertOk() ->assertJsonPath('auth_method', 'password') @@ -84,7 +84,7 @@ public function test_redirect_ignores_invalid_redirect_uri(): void $this->app->instance(\Ssntpl\Neev\Services\TenantSSOManager::class, $manager); // Invalid redirect_uri (external domain) - $response = $this->get('/sso/redirect?redirect_uri=' . urlencode('https://malicious.com/steal')); + $response = $this->get('/neev/sso/redirect?redirect_uri=' . urlencode('https://malicious.com/steal')); $response->assertRedirect(); // Should not store invalid redirect_uri in session @@ -115,7 +115,7 @@ public function test_redirect_passes_email_as_login_hint(): void $manager->shouldReceive('buildSocialiteDriver')->andReturn($driver); $this->app->instance(\Ssntpl\Neev\Services\TenantSSOManager::class, $manager); - $response = $this->get('/sso/redirect?email=user@example.com'); + $response = $this->get('/neev/sso/redirect?email=user@example.com'); $response->assertRedirect(); } diff --git a/tests/Feature/Auth/TenantSSOTest.php b/tests/Feature/Auth/TenantSSOTest.php index 19d4476..8a461f0 100644 --- a/tests/Feature/Auth/TenantSSOTest.php +++ b/tests/Feature/Auth/TenantSSOTest.php @@ -62,7 +62,7 @@ public function test_auth_config_returns_password_when_no_tenant(): void { $this->setCurrentTenant(null); - $response = $this->getJson('/api/tenant/auth'); + $response = $this->getJson('/neev/tenant/auth'); $response->assertOk() ->assertJsonPath('auth_method', 'password') @@ -79,7 +79,7 @@ public function test_auth_config_returns_sso_when_tenant_has_sso(): void $this->setCurrentTenant($team); - $response = $this->getJson('/api/tenant/auth'); + $response = $this->getJson('/neev/tenant/auth'); $response->assertOk() ->assertJsonPath('auth_method', 'sso') @@ -97,7 +97,7 @@ public function test_auth_config_returns_password_when_tenant_uses_password(): v $this->setCurrentTenant($team); - $response = $this->getJson('/api/tenant/auth'); + $response = $this->getJson('/neev/tenant/auth'); $response->assertOk() ->assertJsonPath('auth_method', 'password'); @@ -111,7 +111,7 @@ public function test_redirect_returns_error_when_no_tenant(): void { $this->setCurrentTenant(null); - $response = $this->getJson('/sso/redirect'); + $response = $this->getJson('/neev/sso/redirect'); $response->assertStatus(400); } @@ -126,7 +126,7 @@ public function test_redirect_redirects_to_login_when_sso_not_required(): void $this->setCurrentTenant($team); - $response = $this->get('/sso/redirect'); + $response = $this->get('/neev/sso/redirect'); $response->assertRedirect(route('login')); } @@ -141,7 +141,7 @@ public function test_redirect_returns_error_when_sso_not_configured(): void $this->setCurrentTenant($team); - $response = $this->getJson('/sso/redirect'); + $response = $this->getJson('/neev/sso/redirect'); // Controller calls handleError which returns 400 for JSON $response->assertStatus(400); @@ -155,7 +155,7 @@ public function test_callback_returns_error_when_no_tenant(): void { $this->setCurrentTenant(null); - $response = $this->getJson('/sso/callback'); + $response = $this->getJson('/neev/sso/callback'); $response->assertStatus(400); } @@ -165,7 +165,7 @@ public function test_callback_redirects_to_login_when_no_code(): void $team = TeamFactory::new()->create(); $this->setCurrentTenant($team); - $response = $this->get('/sso/callback'); + $response = $this->get('/neev/sso/callback'); $response->assertRedirect(route('login')); } @@ -175,7 +175,7 @@ public function test_callback_handles_oauth_error_response(): void $team = TeamFactory::new()->create(); $this->setCurrentTenant($team); - $response = $this->get('/sso/callback?error=access_denied&error_description=User+cancelled'); + $response = $this->get('/neev/sso/callback?error=access_denied&error_description=User+cancelled'); $response->assertRedirect(route('login')); } @@ -207,7 +207,7 @@ public function test_callback_successful_web_login(): void $manager->shouldReceive('ensureMembership')->once(); $this->app->instance(TenantSSOManager::class, $manager); - $response = $this->get('/sso/callback?code=auth-code-123'); + $response = $this->get('/neev/sso/callback?code=auth-code-123'); $response->assertRedirect('/dashboard'); } @@ -239,7 +239,7 @@ public function test_callback_successful_spa_login_with_redirect_uri(): void // Store redirect_uri in session (simulating the redirect step) session(['sso_redirect_uri' => 'http://localhost/app']); - $response = $this->get('/sso/callback?code=auth-code-123'); + $response = $this->get('/neev/sso/callback?code=auth-code-123'); $response->assertRedirect(); $this->assertStringContainsString('http://localhost/app', $response->headers->get('Location')); @@ -278,7 +278,7 @@ public function test_callback_spa_login_with_stateful_redirect_uri_uses_cookie_n session(['sso_redirect_uri' => 'https://app.example.com/dashboard']); - $response = $this->get('/sso/callback?code=auth-code-123'); + $response = $this->get('/neev/sso/callback?code=auth-code-123'); $response->assertRedirect(); $location = $response->headers->get('Location'); @@ -317,7 +317,7 @@ public function test_redirect_with_valid_sso_redirects_to_provider(): void ->andReturn($driver); $this->app->instance(TenantSSOManager::class, $manager); - $response = $this->get('/sso/redirect'); + $response = $this->get('/neev/sso/redirect'); $response->assertRedirect(); $this->assertStringContainsString('login.microsoftonline.com', $response->headers->get('Location')); @@ -350,7 +350,7 @@ public function test_redirect_stores_redirect_uri_in_session(): void $manager->shouldReceive('buildSocialiteDriver')->andReturn($driver); $this->app->instance(TenantSSOManager::class, $manager); - $response = $this->get('/sso/redirect?redirect_uri=' . urlencode('https://app.example.com/dashboard')); + $response = $this->get('/neev/sso/redirect?redirect_uri=' . urlencode('https://app.example.com/dashboard')); $response->assertRedirect(); $this->assertEquals('https://app.example.com/dashboard', session('sso_redirect_uri')); @@ -373,7 +373,7 @@ public function test_redirect_handles_socialite_exception(): void ->andThrow(new Exception('Connection failed')); $this->app->instance(TenantSSOManager::class, $manager); - $response = $this->get('/sso/redirect'); + $response = $this->get('/neev/sso/redirect'); $response->assertRedirect(route('login')); } @@ -392,7 +392,7 @@ public function test_callback_handles_sso_manager_exception(): void ->andThrow(new Exception('Provider error')); $this->app->instance(TenantSSOManager::class, $manager); - $response = $this->get('/sso/callback?code=auth-code-123'); + $response = $this->get('/neev/sso/callback?code=auth-code-123'); $response->assertRedirect(route('login')); }