Skip to content

Commit 855b943

Browse files
committed
OCID Auth
1 parent 1599aae commit 855b943

File tree

10 files changed

+540
-19
lines changed

10 files changed

+540
-19
lines changed

AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,12 @@ This repository contains the core package for building microservices using Larav
4141

4242
- Keep pull requests small and focused to ease review.
4343
- Prefer expressive naming and add comments where logic is complex.
44-
- Any new feature should include corresponding tests.
44+
- Any new feature should include corresponding tests.
45+
46+
## OIDC Integration (Keycloak-ready)
47+
48+
- Tokens issued by any OpenID Connect provider can be validated via JWKS by setting `OIDC_ENABLED=true` and `OIDC_JWKS_URL` to the JWKS endpoint (for Keycloak: `/realms/{realm}/protocol/openid-connect/certs`). When JWKS is configured, `JWT_PUBLIC_KEY_PATH` becomes optional.
49+
- Map the authenticated user's identifier with `JWT_USER_IDENTIFIER_CLAIM` (defaults to `id`; set to `sub` when mirroring Keycloak) so permission lookups use the desired claim.
50+
- Use `OIDC_CLIENT_ID` to limit permission extraction to a specific client application. Override claim paths with `OIDC_CLIENT_ROLES_CLAIM`, `OIDC_PRIMARY_ROLES_CLAIM`, `JWT_ROLES_CLAIM`, or `JWT_PERMISSIONS_CLAIM` when the token payload is customized.
51+
- Disable redundant gateway lookups when roles and permissions are already embedded in the token by leaving `OIDC_PREFER_GATEWAY_PERMISSIONS=false`; set it to `true` if the gateway remains the authority.
52+
- Always run `composer test` after updating authentication flows—new coverage exists for the JWT middleware and JWKS resolver.

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- JWKS support and configurable claim mapping for OpenID Connect-issued JWTs (Keycloak-ready).
12+
- New configuration toggles to align `ExternalUser` identifiers and reuse token roles/permissions without gateway calls.
13+
- PHPUnit coverage for the JWT middleware and JWKS validator.
14+
1015
### Changed
1116
- Added type hints to gateway utilities for stronger typing.
1217
- Sanitized JWT key logging in `GatewayGuard` to avoid exposing sensitive data.

src/Auth/ExternalUser.php

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,56 @@ class ExternalUser extends Authenticatable implements AccessUserInterface
1212
use HasAccess;
1313
use Notifiable;
1414

15-
protected $attributes = [];
15+
protected $guarded = [];
16+
17+
protected array $claims = [];
1618

1719
public function __construct(array $attributes = [])
1820
{
1921
parent::__construct([]);
20-
$this->attributes = $attributes;
22+
$this->fillAttributes($attributes);
23+
}
24+
25+
protected function fillAttributes(array $attributes): void
26+
{
27+
if (empty($attributes)) {
28+
return;
29+
}
30+
31+
$this->claims = $attributes['token_claims'] ?? $attributes;
32+
33+
if (isset($attributes['token_claims'])) {
34+
unset($attributes['token_claims']);
35+
}
36+
37+
$this->forceFill($attributes);
2138
$this->syncOriginal();
2239
}
2340

41+
public function setClaims(array $claims): void
42+
{
43+
$this->claims = $claims;
44+
}
45+
46+
public function getClaims(): array
47+
{
48+
return $this->claims;
49+
}
50+
2451
public function getAuthIdentifierName()
2552
{
26-
return 'id';
53+
return config('microservice.auth.user_identifier_claim', 'id');
2754
}
2855

2956
public function getAuthIdentifier()
3057
{
31-
return $this->attributes[$this->getAuthIdentifierName()] ?? null;
58+
$identifier = parent::getAuthIdentifier();
59+
60+
if ($identifier === null && $this->getAuthIdentifierName() !== 'sub') {
61+
return $this->getAttribute('sub');
62+
}
63+
64+
return $identifier;
3265
}
3366

3467
public function getAuthPassword()

src/Auth/GatewayGuard.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ public function user(): ?AuthenticatableContract
6565
if ($data) {
6666
$model = $this->userModel;
6767
$user = new $model($data);
68+
if (method_exists($user, 'setClaims')) {
69+
$user->setClaims($data);
70+
}
6871
if ($this->loadAccess && $user instanceof AccessUserInterface) {
6972
$user->loadAccess($data['roles'] ?? [], $data['permissions'] ?? []);
7073
}
@@ -157,6 +160,9 @@ public function attempt(array $credentials = [], $remember = false): bool
157160
$userData = $response['user'] ?? $this->client->me($token);
158161
$model = $this->userModel;
159162
$user = new $model($userData);
163+
if (method_exists($user, 'setClaims')) {
164+
$user->setClaims($userData);
165+
}
160166
if ($this->loadAccess && $user instanceof AccessUserInterface) {
161167
$user->loadAccess($userData['roles'] ?? [], $userData['permissions'] ?? []);
162168
}
@@ -183,6 +189,10 @@ public function loginWithToken(string $token, array $userData = [], bool $rememb
183189
$model = $this->userModel;
184190
$user = new $model($userData);
185191

192+
if (method_exists($user, 'setClaims')) {
193+
$user->setClaims($userData);
194+
}
195+
186196
if ($this->loadAccess && $user instanceof AccessUserInterface) {
187197
$user->loadAccess($userData['roles'] ?? [], $userData['permissions'] ?? []);
188198
}

src/Http/Middleware/LoadAccess.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ public function handle(Request $request, Closure $next): Response
3333
}
3434

3535
if ($user instanceof AccessUserInterface) {
36+
$oidcConfig = config('microservice.auth.oidc', []);
37+
$preferGateway = (bool) ($oidcConfig['prefer_gateway_permissions'] ?? false);
38+
39+
if (($oidcConfig['enabled'] ?? false) && ! $preferGateway) {
40+
if (! empty($user->getRoleNames()) || ! empty($user->getPermissions())) {
41+
return $next($request);
42+
}
43+
}
44+
3645
try {
3746
$access = app(PermissionsClient::class)->getAccessFor($user);
3847
$user->loadAccess(

src/Http/Middleware/ValidateJwt.php

Lines changed: 185 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Closure;
66
use Illuminate\Http\Request;
7+
use Illuminate\Support\Arr;
78
use Illuminate\Support\Facades\Auth;
89
use Kroderdev\LaravelMicroserviceCore\Auth\ExternalUser;
910
use Kroderdev\LaravelMicroserviceCore\Services\JwtValidator;
@@ -34,16 +35,20 @@ public function handle(Request $request, Closure $next): Response
3435

3536
try {
3637
$decoded = $this->validator->decode($token);
38+
$claims = $this->toArray($decoded);
39+
40+
$attributes = $this->buildUserAttributes($claims);
41+
42+
$user = new ExternalUser($attributes);
43+
$user->setClaims($claims);
44+
45+
$roles = $this->extractRoles($claims);
46+
$permissions = $this->extractPermissions($claims, $roles);
47+
48+
$user->loadAccess($roles, $permissions);
3749

38-
// Auth from JWT
39-
$user = new ExternalUser(['sub' => $decoded->sub]);
40-
$user->loadAccess(
41-
$decoded->roles ?? [],
42-
$decoded->permissions ?? []
43-
);
4450
Auth::setUser($user);
4551
$request->setUserResolver(fn () => $user);
46-
4752
} catch (\Throwable $e) {
4853
if ($request->expectsJson()) {
4954
return response()->json(['error' => 'Invalid token', 'message' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
@@ -54,4 +59,177 @@ public function handle(Request $request, Closure $next): Response
5459

5560
return $next($request);
5661
}
62+
63+
protected function toArray(mixed $value): array
64+
{
65+
if ($value instanceof \stdClass) {
66+
$value = (array) $value;
67+
}
68+
69+
if (! is_array($value)) {
70+
return [];
71+
}
72+
73+
foreach ($value as $key => $item) {
74+
if ($item instanceof \stdClass || is_array($item)) {
75+
$value[$key] = $this->toArray($item);
76+
}
77+
}
78+
79+
return $value;
80+
}
81+
82+
protected function buildUserAttributes(array $claims): array
83+
{
84+
$attributes = $claims;
85+
$identifierClaim = config('microservice.auth.user_identifier_claim', 'id');
86+
87+
if (! isset($attributes[$identifierClaim]) && isset($attributes['sub'])) {
88+
$attributes[$identifierClaim] = $attributes['sub'];
89+
}
90+
91+
if ($identifierClaim === 'id' && isset($attributes['sub'])) {
92+
$attributes['id'] = $attributes['sub'];
93+
}
94+
95+
return $attributes;
96+
}
97+
98+
protected function extractRoles(array $claims): array
99+
{
100+
$roles = $this->resolveClaim($claims, config('microservice.auth.roles_claim'));
101+
102+
if (! empty($roles)) {
103+
return $roles;
104+
}
105+
106+
$primaryRoles = $this->resolveClaim(
107+
$claims,
108+
config('microservice.auth.oidc.primary_roles_claim', 'realm_access.roles')
109+
);
110+
111+
return $primaryRoles;
112+
}
113+
114+
protected function extractPermissions(array $claims, array $roles): array
115+
{
116+
$permissions = $this->resolveClaim($claims, config('microservice.auth.permissions_claim'));
117+
118+
if (! empty($permissions)) {
119+
return $permissions;
120+
}
121+
122+
$oidcConfig = config('microservice.auth.oidc', []);
123+
$permissions = [];
124+
125+
if (($oidcConfig['map_client_roles_to_permissions'] ?? true)) {
126+
$clientClaim = $this->clientRolesPath($oidcConfig);
127+
$paths = array_unique(array_filter([
128+
$clientClaim,
129+
'resource_access.*.roles',
130+
]));
131+
$permissions = $this->resolveClaim($claims, $paths);
132+
133+
if (empty($permissions) && isset($claims['resource_access']) && is_array($claims['resource_access'])) {
134+
$permissions = $this->collectResourceRoles($claims['resource_access'], $oidcConfig['client_id'] ?? null);
135+
}
136+
}
137+
138+
if (empty($permissions) && ($oidcConfig['map_primary_roles_to_permissions'] ?? false)) {
139+
$permissions = $this->resolveClaim(
140+
$claims,
141+
$oidcConfig['primary_roles_claim'] ?? 'realm_access.roles'
142+
);
143+
}
144+
145+
if (empty($permissions)) {
146+
return $roles;
147+
}
148+
149+
return $permissions;
150+
}
151+
152+
protected function collectResourceRoles(array $resourceAccess, ?string $clientId): array
153+
{
154+
$roles = [];
155+
156+
if ($clientId && isset($resourceAccess[$clientId]['roles'])) {
157+
$roles = $resourceAccess[$clientId]['roles'];
158+
} else {
159+
foreach ($resourceAccess as $data) {
160+
if (! is_array($data) || empty($data['roles'])) {
161+
continue;
162+
}
163+
$roles = array_merge($roles, (array) $data['roles']);
164+
}
165+
}
166+
167+
return $this->normalizeAccessValues($roles);
168+
}
169+
170+
protected function clientRolesPath(array $config): ?string
171+
{
172+
$path = $config['client_roles_claim'] ?? 'resource_access.*.roles';
173+
$clientId = $config['client_id'] ?? null;
174+
175+
if ($clientId) {
176+
if (str_contains($path, '%s')) {
177+
return sprintf($path, $clientId);
178+
}
179+
180+
if (str_contains($path, '{client}')) {
181+
return str_replace('{client}', $clientId, $path);
182+
}
183+
}
184+
185+
if (! $clientId && (str_contains($path, '%s') || str_contains($path, '{client}'))) {
186+
return 'resource_access.*.roles';
187+
}
188+
189+
return $path;
190+
}
191+
192+
protected function resolveClaim(array $claims, string|array|null $path): array
193+
{
194+
if (empty($path)) {
195+
return [];
196+
}
197+
198+
$paths = (array) $path;
199+
$values = [];
200+
201+
foreach ($paths as $claimPath) {
202+
if (! $claimPath) {
203+
continue;
204+
}
205+
206+
$value = Arr::get($claims, $claimPath);
207+
$values = array_merge($values, $this->normalizeAccessValues($value));
208+
}
209+
210+
$values = array_values(array_unique($values));
211+
212+
return $values;
213+
}
214+
215+
protected function normalizeAccessValues(mixed $value): array
216+
{
217+
if ($value === null) {
218+
return [];
219+
}
220+
221+
if (! is_array($value)) {
222+
$value = [$value];
223+
}
224+
225+
$items = [];
226+
227+
array_walk_recursive($value, function ($item) use (&$items) {
228+
if (is_string($item) && $item !== '') {
229+
$items[] = $item;
230+
}
231+
});
232+
233+
return $items;
234+
}
57235
}

0 commit comments

Comments
 (0)