Skip to content

Commit 83424d3

Browse files
Add pluggable auth provider contract
1 parent 9a87f47 commit 83424d3

17 files changed

Lines changed: 657 additions & 154 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ CACHE_STORE=redis
2828
# WORKFLOW_* / ACTIVITY_* names are still honored but log a deprecation
2929
# warning at boot via `php artisan env:audit`.
3030

31+
DW_AUTH_PROVIDER=
3132
DW_AUTH_DRIVER=token
3233
DW_AUTH_TOKEN=
3334
DW_WORKER_TOKEN=

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,20 @@ as the legacy bearer token.
586586

587587
Set `DW_AUTH_DRIVER=none` to disable authentication (development only).
588588

589+
### Custom Auth Providers
590+
591+
Set `DW_AUTH_PROVIDER` to the fully-qualified class name of a Laravel
592+
container-resolvable implementation of `App\Contracts\AuthProvider` to replace
593+
the built-in token/signature provider without editing server middleware. The
594+
provider returns an `App\Auth\Principal` from `authenticate(Request $request)`
595+
and receives each route authorization decision as
596+
`authorize(Principal $principal, string $action, array $resource): bool`.
597+
598+
The route resource includes `allowed_roles`, HTTP method/path, route name, and
599+
the resolved namespace when available. The authenticated principal is also
600+
recorded in workflow command attribution so signal/update/query history can
601+
show the subject, roles, tenant, and non-secret claims supplied by the provider.
602+
589603
## Deployment
590604

591605
### Docker
@@ -750,6 +764,7 @@ every operator-facing variable the server honors.
750764
| `DW_SERVER_ID` | `gethostname()` | Unique identifier for this server instance. |
751765
| `DW_DEFAULT_NAMESPACE` | `default` | Namespace used when a request omits the namespace header. |
752766
| `DW_TASK_DISPATCH_MODE` | (unset) | Override for `workflows.v2.task_dispatch_mode`. Set to `queue` to dispatch locally in service mode. |
767+
| `DW_AUTH_PROVIDER` | (unset) | Optional FQCN implementing `App\Contracts\AuthProvider`; unset uses the built-in driver. |
753768
| `DW_AUTH_DRIVER` | `token` | `none`, `token`, or `signature`. |
754769
| `DW_AUTH_TOKEN` | (unset) | Single shared bearer token (backward-compat credential). |
755770
| `DW_SIGNATURE_KEY` | (unset) | HMAC key used when `DW_AUTH_DRIVER=signature` and no role-scoped key is configured. |

app/Auth/AuthException.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Auth;
4+
5+
use RuntimeException;
6+
7+
final class AuthException extends RuntimeException
8+
{
9+
private function __construct(
10+
private readonly int $status,
11+
private readonly string $reason,
12+
string $message,
13+
) {
14+
parent::__construct($message);
15+
}
16+
17+
public static function configuration(string $message): self
18+
{
19+
return new self(500, 'server_error', $message);
20+
}
21+
22+
public static function unauthenticated(string $message): self
23+
{
24+
return new self(401, 'unauthorized', $message);
25+
}
26+
27+
public function status(): int
28+
{
29+
return $this->status;
30+
}
31+
32+
public function reason(): string
33+
{
34+
return $this->reason;
35+
}
36+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
namespace App\Auth;
4+
5+
use App\Contracts\AuthProvider;
6+
use Illuminate\Http\Request;
7+
8+
final class ConfiguredAuthProvider implements AuthProvider
9+
{
10+
private const ROLE_WORKER = 'worker';
11+
12+
private const ROLE_OPERATOR = 'operator';
13+
14+
private const ROLE_ADMIN = 'admin';
15+
16+
private const ROLES = [
17+
self::ROLE_WORKER,
18+
self::ROLE_OPERATOR,
19+
self::ROLE_ADMIN,
20+
];
21+
22+
public function authenticate(Request $request): Principal
23+
{
24+
$driver = (string) config('server.auth.driver', 'none');
25+
26+
return match ($driver) {
27+
'none' => Principal::role(self::ROLE_ADMIN, 'none', legacyFullAccess: true, subject: 'anonymous'),
28+
'token' => $this->authenticateToken($request),
29+
'signature' => $this->authenticateSignature($request),
30+
default => throw AuthException::configuration("Unknown auth driver: {$driver}"),
31+
};
32+
}
33+
34+
public function authorize(Principal $principal, string $action, array $resource = []): bool
35+
{
36+
if ($principal->legacyFullAccess()) {
37+
return true;
38+
}
39+
40+
$allowedRoles = $resource['allowed_roles'] ?? [];
41+
42+
if (! is_array($allowedRoles) || $allowedRoles === []) {
43+
return false;
44+
}
45+
46+
foreach ($allowedRoles as $role) {
47+
if (is_string($role) && $principal->hasRole($role)) {
48+
return true;
49+
}
50+
}
51+
52+
return false;
53+
}
54+
55+
private function authenticateToken(Request $request): Principal
56+
{
57+
$token = config('server.auth.token');
58+
$roleTokens = $this->configuredRoleSecrets('server.auth.role_tokens');
59+
$hasRoleTokens = $roleTokens !== [];
60+
$hasLegacyToken = is_string($token) && $token !== '';
61+
$backwardCompatible = (bool) config('server.auth.backward_compatible', true);
62+
63+
if (! $hasRoleTokens && (! $backwardCompatible || ! $hasLegacyToken)) {
64+
throw AuthException::configuration('Auth driver is set to "token" but DW_AUTH_TOKEN is not configured.');
65+
}
66+
67+
$provided = $request->bearerToken();
68+
69+
if (! $provided) {
70+
throw AuthException::unauthenticated('Invalid or missing authentication token.');
71+
}
72+
73+
foreach ($roleTokens as $role => $secret) {
74+
if (hash_equals($secret, $provided)) {
75+
return Principal::role($role, 'token');
76+
}
77+
}
78+
79+
if ($backwardCompatible && $hasLegacyToken && hash_equals($token, $provided)) {
80+
return Principal::role(
81+
self::ROLE_ADMIN,
82+
'token',
83+
legacyFullAccess: ! $hasRoleTokens,
84+
subject: 'legacy-token',
85+
claims: [
86+
'legacy_credential' => true,
87+
],
88+
);
89+
}
90+
91+
throw AuthException::unauthenticated('Invalid or missing authentication token.');
92+
}
93+
94+
private function authenticateSignature(Request $request): Principal
95+
{
96+
$key = config('server.auth.signature_key');
97+
$roleKeys = $this->configuredRoleSecrets('server.auth.role_signature_keys');
98+
$hasRoleKeys = $roleKeys !== [];
99+
$hasLegacyKey = is_string($key) && $key !== '';
100+
$backwardCompatible = (bool) config('server.auth.backward_compatible', true);
101+
102+
if (! $hasRoleKeys && (! $backwardCompatible || ! $hasLegacyKey)) {
103+
throw AuthException::configuration('Auth driver is set to "signature" but DW_SIGNATURE_KEY is not configured.');
104+
}
105+
106+
$signature = $request->header('X-Signature');
107+
108+
if (! $signature) {
109+
throw AuthException::unauthenticated('Missing request signature.');
110+
}
111+
112+
$body = $request->getContent();
113+
114+
foreach ($roleKeys as $role => $secret) {
115+
$expected = hash_hmac('sha256', $body, $secret);
116+
117+
if (hash_equals($expected, $signature)) {
118+
return Principal::role($role, 'signature');
119+
}
120+
}
121+
122+
if ($backwardCompatible && $hasLegacyKey) {
123+
$expected = hash_hmac('sha256', $body, $key);
124+
125+
if (hash_equals($expected, $signature)) {
126+
return Principal::role(
127+
self::ROLE_ADMIN,
128+
'signature',
129+
legacyFullAccess: ! $hasRoleKeys,
130+
subject: 'legacy-signature',
131+
claims: [
132+
'legacy_credential' => true,
133+
],
134+
);
135+
}
136+
}
137+
138+
throw AuthException::unauthenticated('Invalid request signature.');
139+
}
140+
141+
/**
142+
* @return array<string, string>
143+
*/
144+
private function configuredRoleSecrets(string $configKey): array
145+
{
146+
$configured = config($configKey, []);
147+
148+
if (! is_array($configured)) {
149+
return [];
150+
}
151+
152+
$secrets = [];
153+
154+
foreach (self::ROLES as $role) {
155+
$secret = $configured[$role] ?? null;
156+
157+
if (is_string($secret) && $secret !== '') {
158+
$secrets[$role] = $secret;
159+
}
160+
}
161+
162+
return $secrets;
163+
}
164+
}

app/Auth/Principal.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace App\Auth;
4+
5+
final class Principal
6+
{
7+
/**
8+
* @var array<int, string>
9+
*/
10+
private readonly array $roles;
11+
12+
/**
13+
* @param array<int, string> $roles
14+
* @param array<string, mixed> $claims
15+
*/
16+
public function __construct(
17+
private readonly string $subject,
18+
array $roles = [],
19+
private readonly string $method = 'none',
20+
private readonly ?string $tenant = null,
21+
private readonly array $claims = [],
22+
private readonly bool $legacyFullAccess = false,
23+
) {
24+
$this->roles = self::normalizeRoles($roles);
25+
}
26+
27+
/**
28+
* @param array<string, mixed> $claims
29+
*/
30+
public static function role(
31+
string $role,
32+
string $method,
33+
bool $legacyFullAccess = false,
34+
?string $subject = null,
35+
?string $tenant = null,
36+
array $claims = [],
37+
): self {
38+
return new self(
39+
subject: $subject ?? "role:{$role}",
40+
roles: [$role],
41+
method: $method,
42+
tenant: $tenant,
43+
claims: $claims,
44+
legacyFullAccess: $legacyFullAccess,
45+
);
46+
}
47+
48+
public function subject(): string
49+
{
50+
return $this->subject;
51+
}
52+
53+
/**
54+
* @return array<int, string>
55+
*/
56+
public function roles(): array
57+
{
58+
return $this->roles;
59+
}
60+
61+
public function primaryRole(): ?string
62+
{
63+
return $this->roles[0] ?? null;
64+
}
65+
66+
public function hasRole(string $role): bool
67+
{
68+
return in_array($role, $this->roles, true);
69+
}
70+
71+
public function method(): string
72+
{
73+
return $this->method;
74+
}
75+
76+
public function tenant(): ?string
77+
{
78+
return $this->tenant;
79+
}
80+
81+
/**
82+
* @return array<string, mixed>
83+
*/
84+
public function claims(): array
85+
{
86+
return $this->claims;
87+
}
88+
89+
public function legacyFullAccess(): bool
90+
{
91+
return $this->legacyFullAccess;
92+
}
93+
94+
/**
95+
* @return array<string, mixed>
96+
*/
97+
public function toAuditContext(): array
98+
{
99+
return array_filter([
100+
'subject' => $this->subject,
101+
'roles' => $this->roles,
102+
'tenant' => $this->tenant,
103+
'claims' => $this->claims,
104+
'legacy_full_access' => $this->legacyFullAccess ?: null,
105+
], static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
106+
}
107+
108+
/**
109+
* @param array<int, string> $roles
110+
* @return array<int, string>
111+
*/
112+
private static function normalizeRoles(array $roles): array
113+
{
114+
$normalized = [];
115+
116+
foreach ($roles as $role) {
117+
$role = trim($role);
118+
119+
if ($role !== '') {
120+
$normalized[] = $role;
121+
}
122+
}
123+
124+
return array_values(array_unique($normalized));
125+
}
126+
}

app/Contracts/AuthProvider.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Contracts;
4+
5+
use App\Auth\Principal;
6+
use Illuminate\Http\Request;
7+
8+
interface AuthProvider
9+
{
10+
public function authenticate(Request $request): Principal;
11+
12+
/**
13+
* @param array<string, mixed> $resource
14+
*/
15+
public function authorize(Principal $principal, string $action, array $resource = []): bool;
16+
}

0 commit comments

Comments
 (0)