Skip to content

Commit 757a8bc

Browse files
bizastromcom
authored andcommitted
initial commit
0 parents  commit 757a8bc

49 files changed

Lines changed: 4312 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: "CI"
2+
3+
on:
4+
push:
5+
branches: ["**"]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
static-analysis:
13+
name: "Static analysis (PHP ${{ matrix.php }})"
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 5
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
php: ['8.3', '8.4']
20+
steps:
21+
- uses: actions/checkout@v5
22+
- name: Setup PHP
23+
uses: shivammathur/setup-php@v2
24+
with:
25+
php-version: ${{ matrix.php }}
26+
coverage: none
27+
- name: Validate composer.json
28+
run: composer validate --strict --no-check-lock
29+
- name: Cache Composer
30+
uses: actions/cache@v5
31+
with:
32+
path: vendor
33+
key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.json') }}
34+
restore-keys: |
35+
${{ runner.os }}-php-${{ matrix.php }}-
36+
- name: Install dependencies
37+
run: composer install --prefer-dist --no-progress
38+
- name: Run PHPStan
39+
run: composer run-script phpstan
40+
41+
tests:
42+
name: "Tests (PHP ${{ matrix.php }})"
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 5
45+
strategy:
46+
fail-fast: false
47+
matrix:
48+
php: ['8.3', '8.4']
49+
steps:
50+
- uses: actions/checkout@v5
51+
- name: Setup PHP
52+
uses: shivammathur/setup-php@v2
53+
with:
54+
php-version: ${{ matrix.php }}
55+
coverage: none
56+
- name: Cache Composer
57+
uses: actions/cache@v5
58+
with:
59+
path: vendor
60+
key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.json') }}
61+
restore-keys: |
62+
${{ runner.os }}-php-${{ matrix.php }}-
63+
- name: Install dependencies
64+
run: composer install --prefer-dist --no-progress
65+
- name: Run PHPUnit
66+
run: composer run-script test

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/vendor/
2+
/composer.lock
3+
/tmp/*
4+
!/tmp/.gitkeep

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Changelog
2+
3+
All notable changes to this package are documented here.
4+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5+
this package adheres to [Semantic Versioning](https://semver.org/).
6+
7+
## [1.0.0] — 2026-05-13
8+
9+
Initial public release.
10+
11+
### Added
12+
13+
- `Client::beginAuthorization()` — build Authorization Code + PKCE URL with
14+
`state` and `Pkce` pair.
15+
- `Client::exchangeCode()` — exchange authorization code for `TokenSet`.
16+
- `Client::refresh()` — refresh access token (rotation supported).
17+
- `Client::clientCredentials()` — machine-to-machine flow.
18+
- `Client::userInfo()` — call `/me` with a Bearer token.
19+
- `Client::verify()` — local JWT verification via JWKS.
20+
- `Client::logoutUrl()` — build end-session URL.
21+
- `Client::discover()` — fetch OIDC discovery document.
22+
- `Claims` value object with rich API:
23+
`hasRole`, `hasAnyRole`, `hasAllRoles`, `hasProjectRole`, `rolesForProject`,
24+
`hasGroup`, `hasAnyGroup`, `hasAllGroups`, `hasScope`, `requireRole`,
25+
`requireAnyRole`, `requireGroup`, `requireScope`, `requireUserToken`,
26+
`requireServiceToken`, `isExpired`, `secondsUntilExpiration`,
27+
`displayName`, `audience`, `claim`.
28+
- `TokenSet` with `isExpired`, `authorizationHeader`.
29+
- `Pkce::generate()` and `Pkce::challengeFor()`.
30+
- `TokenVerifier` with strict RFC 9068 enforcement (`iss`, `token_use`, `aud`).
31+
- `JwksCacheInterface` with `InMemoryJwksCache`, `ApcuJwksCache` and `FileJwksCache` implementations.
32+
- `HttpClientInterface` with `CurlHttpClient` default implementation.
33+
- Exception hierarchy: `AuthClientException` (base), `ConfigurationException`,
34+
`TransportException`, `OAuthServerException`, `TokenVerificationException`,
35+
`AuthorizationException`.
36+
37+
### Technical decisions
38+
39+
- **JWT parsing and validation via `lcobucci/jwt: ^5.5`.** The auth server
40+
uses the same library transitively (through `league/oauth2-server`), so
41+
both ends share the same JWT interpretation. `firebase/php-jwt` was
42+
rejected due to current composer audit advisories.
43+
- **JWK→PEM bridge in `Internal/JwkRsaKey`.** `lcobucci/jwt` accepts PEM keys
44+
only; JWKS publishes JWK. The bridge emits standard ASN.1
45+
SubjectPublicKeyInfo (≈70 LoC).
46+
- **In-house PSR-20 `SystemClock`.** Avoids pulling `lcobucci/clock` for a
47+
one-method class.
48+
- **Strict mode by default.** `iss` and `token_use` claims are required;
49+
missing or empty values raise `TokenVerificationException`.
50+
- **JWKS cache `kid`-rotation.** On `kid` miss, the cache is invalidated
51+
and re-fetched once automatically before failing — supports key rotation
52+
without restart.
53+
- **Defensive parsing of optional `scopes` shapes.** Accepts both
54+
whitespace-separated string and `list<string>`.

CLAUDE.md

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# CLAUDE.md — guidance for Claude Code in this repository
2+
3+
You are working on **`stromcom/auth-client`** — the official PHP client SDK
4+
for the `auth.stromcom.cz` SSO/OAuth 2.0 server. This file collects everything
5+
you need to keep this package consistent across future sessions.
6+
7+
## What this package is
8+
9+
Self-contained PHP 8.3+ client library:
10+
- OAuth 2.0 Authorization Code + PKCE flow (web app login)
11+
- OAuth 2.0 Client Credentials flow (machine-to-machine)
12+
- Refresh token grant
13+
- Local JWT verification via JWKS with TTL cache and `kid`-rotation
14+
- UserInfo (`/me`) call
15+
- Logout URL builder
16+
- OIDC discovery (`/.well-known/openid-configuration`)
17+
- RFC 9068 strict (`iss`, `token_use`, `at+jwt` required)
18+
19+
**Production runtime dependencies:** only `lcobucci/jwt: ^5.5` (and its
20+
transitive `psr/clock`). JWT parsing, signature verification and temporal
21+
claim validation go through `lcobucci/jwt`. JWKS fetching, caching, key
22+
rotation, OAuth grant flows and PKCE are in-house. cURL is the HTTP
23+
transport.
24+
25+
## Server coupling
26+
27+
This SDK targets the `auth.stromcom.cz` server. The server is the source of
28+
truth for endpoints and the JWT claim contract. When adding a feature here,
29+
check whether the server already supports it. Don't invent endpoints — if
30+
the SDK needs a new server feature, change the server first.
31+
32+
## Code style
33+
34+
- PHP 8.3+, `declare(strict_types=1);`
35+
- 2-space indent
36+
- `final class Foo {` with same-line brace
37+
- Constructor property promotion with `public readonly` everywhere possible
38+
- camelCase method/property names
39+
- No PHPDoc unless adding type information beyond what PHP can express
40+
(array shapes, `@throws` on interfaces, examples on `Client` methods)
41+
- **No comments unless the WHY is non-obvious.** Code shouldn't explain what
42+
it does — names should. Comment only the surprising bits: a workaround for
43+
a third-party bug, a non-obvious invariant, a security-critical decision.
44+
45+
If you ever need an example of what good prose looks like, read the existing
46+
comments in `src/TokenVerifier.php` and `src/Internal/JwkRsaKey.php`. The
47+
comment in `LeagueAccessTokenEntity.php` on the server is also a good model.
48+
49+
## Layered structure
50+
51+
```
52+
src/
53+
├── Client.php # Main facade. Per-flow methods.
54+
├── Configuration.php # Immutable config + endpoint derivation.
55+
├── TokenSet.php # The /oauth/token response, parsed.
56+
├── Claims.php # The decoded JWT payload, with helpers.
57+
├── Pkce.php # PKCE verifier + challenge generator.
58+
├── TokenVerifier.php # JWKS-based RS256 verification.
59+
├── Http/
60+
│ ├── HttpClientInterface.php # Tiny PSR-7-ish interface (no PSR-7 dep).
61+
│ ├── CurlHttpClient.php # Default cURL transport.
62+
│ └── RawResponse.php
63+
├── Jwks/
64+
│ ├── JwksCacheInterface.php # Cache contract.
65+
│ ├── InMemoryJwksCache.php # Per-process. Good for CLI / workers.
66+
│ ├── ApcuJwksCache.php # Shared memory. Right default for Lambda + FPM.
67+
│ └── FileJwksCache.php # Single-host fallback when APCu is unavailable.
68+
├── Internal/
69+
│ ├── JwkRsaKey.php # JWK (n, e) → PEM via ASN.1.
70+
│ └── SystemClock.php # PSR-20 clock for lcobucci's LooseValidAt constraint.
71+
└── Exception/
72+
├── AuthClientException.php # Base (catch-all).
73+
├── ConfigurationException.php
74+
├── TransportException.php
75+
├── OAuthServerException.php # Server returned an OAuth `error` payload.
76+
├── TokenVerificationException.php
77+
└── AuthorizationException.php # Role/group/scope/token_use guard.
78+
```
79+
80+
**`Internal/` is for plumbing.** Anything under it is `@internal` and not part
81+
of the SDK's public API. Don't expose new helpers there to consumers — if a
82+
helper deserves to be public, move it up.
83+
84+
## Architectural rules
85+
86+
1. **Runtime dependencies are deliberately tiny.** Only `lcobucci/jwt` (and
87+
its transitive `psr/clock`). If you want to add another, justify it
88+
against the cost of every consumer pulling it in. Dev-dependencies
89+
(PHPUnit, PHPStan, php-cs-fixer) are unconstrained.
90+
2. **Don't replace `lcobucci/jwt`.** The auth server uses it transitively
91+
through `league/oauth2-server`, so both ends share the same JWT
92+
interpretation. Switching to `firebase/php-jwt` is a downgrade — it's
93+
currently flagged by `composer audit`. Switching to `web-token/jwt-framework`
94+
drags in a much larger dependency surface for the same job.
95+
3. **`Client` is the only public facade.** Don't add competing entry points.
96+
New flows are new methods on `Client`.
97+
4. **HTTP transport is injectable.** All HTTP goes through
98+
`HttpClientInterface`. Never call cURL directly anywhere else.
99+
5. **JWKS cache is injectable.** Always go through `JwksCacheInterface`.
100+
6. **Strict by default.** RFC 9068 requires `iss`, `token_use`. Don't add a
101+
"lenient mode" toggle without a strong reason — strict-in-what-you-accept
102+
is the security default and the server emits those claims.
103+
7. **`Claims` is read-only and rich.** When users ask "how do I check X",
104+
the answer should be "Claims has a method for that". Add to the API rather
105+
than telling users to dig into `$claims->all`.
106+
8. **Tests are unit + offline.** Never hit the network from unit tests. For
107+
end-to-end checks, `examples/smoke.php` exists and is run manually.
108+
109+
## Adding features
110+
111+
### A new grant type
112+
- Add a method on `Client` (mirror `clientCredentials()` / `exchangeCode()`).
113+
- Reuse `postToken()` — don't duplicate the request building.
114+
- Update `docs/auth-code-flow.md` or `docs/service-account.md` (or add a new doc).
115+
- Add a unit test against `Client` with a mock `HttpClientInterface`.
116+
117+
### A new claim from the server
118+
- If it's an OIDC-standard claim → map it explicitly in `Claims::fromPayload`.
119+
- If it's a stromcom-specific claim → same, plus add convenience methods
120+
(`has*`, `require*`, `*ForProject`, etc.).
121+
- Update the "Claims — object API" section of `README.md`.
122+
123+
### A new exception case
124+
- Always extend `AuthClientException` (so a top-level `catch` works).
125+
- Don't introduce a new exception that doesn't have at least one named
126+
factory method or constructor parameter capturing the failure context.
127+
128+
## Server-side coupling
129+
130+
This SDK is paired with `auth.stromcom.cz`. **Specific contract relied on:**
131+
132+
- `GET /.well-known/jwks.json` — JWKS, RS256, `kid` from first 16 hex chars
133+
of `sha256(public_pem)`.
134+
- `GET /.well-known/openid-configuration` — OIDC discovery.
135+
- `POST /oauth/token` — grants: `authorization_code`, `refresh_token`,
136+
`client_credentials`.
137+
- `GET /oauth/authorize` — PKCE S256 supported.
138+
- `GET /me` — UserInfo with `Authorization: Bearer …`.
139+
- `GET /oauth/logout` — end-session, optional `post_logout_redirect_uri`.
140+
141+
**JWT contract:**
142+
- Header: `{typ: "at+jwt", alg: "RS256", kid: "..."}`
143+
- Always present: `iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `scopes`, `token_use`
144+
- Service tokens add: `client_id`, `client_name`, `roles`, `is_admin`
145+
- User tokens add (filtered by scope, OIDC Core 1.0 §5.4):
146+
`name`, `email`, `email_verified`, `picture`, `locale`, `zoneinfo`,
147+
`updated_at`, `roles`, `groups`, `is_admin`
148+
149+
If the server changes any of this, this SDK needs corresponding updates.
150+
151+
## Testing
152+
153+
```bash
154+
composer install
155+
composer test # PHPUnit, no network
156+
composer phpstan # static analysis, level 8
157+
composer ca # phpstan + test
158+
```
159+
160+
Unit tests live in `tests/` and mirror the `src/` layout. Use offline
161+
fixtures — JWKS sample documents, baked test keypairs (Windows PHP doesn't
162+
have `openssl.cnf` for runtime keygen).
163+
164+
The smoke example (`examples/smoke.php`) is **the** end-to-end test. Run it
165+
manually against a running auth server with a service-account client:
166+
167+
```bash
168+
AUTH_ISSUER=https://auth.stromcom.cz AUTH_CLIENT_ID=svc_… AUTH_CLIENT_SECRET=… \
169+
php examples/smoke.php
170+
```
171+
172+
## Documentation
173+
174+
- `README.md` — entry point, quickstart, claim API reference.
175+
- `docs/architecture.md` — internals.
176+
- `docs/auth-code-flow.md` — web app integration deep dive.
177+
- `docs/service-account.md` — M2M deep dive.
178+
- `docs/jwt-verification.md` — JWKS, claims, key rotation.
179+
- `docs/error-handling.md` — exception hierarchy + retry strategies.
180+
- `docs/security.md` — PKCE, state, secret storage, token storage.
181+
- `CHANGELOG.md` — semantic-versioned changelog.
182+
- `examples/*.php` — runnable examples, each self-contained.
183+
184+
**Keep these in sync.** If you change a public API:
185+
1. Update the relevant `docs/*.md`.
186+
2. Update `README.md` if it appears in the quickstart or reference table.
187+
3. Add a `CHANGELOG.md` entry.
188+
4. Add or update an example demonstrating the change.
189+
190+
## What this package is NOT
191+
192+
- Not a generic OAuth 2.0 / OIDC library. It targets exactly one server
193+
(`auth.stromcom.cz`). Don't generalize without a clear reason.
194+
- Not an RFC 9068 reference. We aim for compliance with what auth.stromcom.cz
195+
emits; we don't aim to validate every weird edge case some other server
196+
might produce.
197+
- Not a session manager. Users persist tokens themselves (cookies, DB,
198+
whatever). The SDK only fetches and verifies.
199+
- Not a "framework". No autowiring, no DI container integration, no service
200+
provider, no PSR-7 emitter. Plain objects.
201+
202+
## Common questions
203+
204+
**"Should I add Guzzle support?"** No. The `HttpClientInterface` is one
205+
method wide. If a user wants Guzzle, they write an 8-line adapter. Adding a
206+
Guzzle dependency means every consumer of this SDK pulls in Guzzle.
207+
208+
**"Should I add PSR-3 logging?"** Not yet. If you ever do, accept a
209+
`?LoggerInterface` on `Client` and log nothing by default. Don't take a hard
210+
dep on `psr/log`.
211+
212+
**"Should I cache tokens automatically?"** No — token storage is the
213+
caller's concern (they know where their session lives). Provide a recipe in
214+
`examples/service-account-cached.php` instead.
215+
216+
**"User asks for a feature the server doesn't support."** Fix the server
217+
first. This SDK should never paper over a missing server feature with a
218+
client-side workaround (the one exception: defensive parsing of optional
219+
fields). If you're tempted, stop and explain.
220+
221+
## When in doubt
222+
223+
Read the existing code. It's small (~1200 lines), idiomatic, and the
224+
conventions are consistent. New code that breaks the conventions is the
225+
problem, not the conventions.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) STROMCOM s.r.o.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)