Skip to content

About

Verify FLINK results (ID Austria prefill) in a Laravel backend

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

flink-sdk-php

Verify FLINK's signed assertions in a Laravel backend. An embedded FLINK journey hands your page the applicant's eID data (from ID Austria, for example) together with a copy signed by FLINK. This package verifies that signed copy, so your backend can check that the names and birthdate in a submitted form are the ones the eID returned. The same verifier checks FLINK's other signed assertions, such as a session's results.

Using Statamic? Follow docs/statamic.md end to end.

Install

composer require mohemian/flink-sdk-php
php artisan flink:install   # publishes config/flink.php and lists what is still unset

PHP 8.2+ (8.3+ for Laravel 13), Laravel 10 to 13, ext-intl.

FLINK_TENANT_ID=…   # the console's journey page, "Start & integrate"
FLINK_JOURNEYS=…    # the journey's public id, same place; several comma-separated
# FLINK_URL=https://flink-services.mohemian.rocks  (default)
# FLINK_AUDIENCE=https://www.example.at            (default: APP_URL's origin)
# FLINK_RESULTS_AUDIENCE=…                         (only to verify results assertions)

Verify a prefill

On the page, launch the journey with this session's reference and post prefillJwt from onResult to your backend (see the browser SDK docs):

<script>
  Flink.createFlink({ journey: 'SCD7DDPK258G', reference: '@flinkReference', onResult: … });
</script>

On the backend:

use Mohemian\Flink\Exceptions\FlinkException;
use Mohemian\Flink\Facades\Flink;

try {
    $prefill = Flink::prefill($request->input('prefill_jwt')); // checked against this session's reference
} catch (FlinkException $e) {
    logger()->warning('FLINK prefill refused', ['reason' => $e->getMessage()]); // never log the JWT

    return response()->noContent(422);
}

$prefill->data;                    // ['givenName' => 'Erika', 'familyName' => 'Mustermann', 'address' => [...]]
$prefill->get('address.street');   // 'Hauptstraße'
$prefill->journey; $prefill->method; $prefill->sessionId; $prefill->id; $prefill->expiresAt;
$prefill->changed($submitted);     // e.g. ['address.street']

Or as a validation rule, which keeps the verified prefill for you:

use Mohemian\Flink\Rules\ValidPrefill;

$request->validate(['prefill_jwt' => ['required', $rule = new ValidPrefill]]);
$rule->assertion->data;

Flink::prefill() throws a FlinkException unless every one of these holds:

  • signed by FLINK: RS256 only, key chosen by kid from FLINK's JWKS (cached a day, refetched at most once a minute for an unknown kid), header typ = flow-prefill+jwt; otherwise InvalidSignature, or InvalidAssertion for another typ
  • iss = FLINK_URL, aud = your page's origin, not expired, ver = 1 (iat and exp allow 5 seconds of clock skew)
  • tenant_id is yours and journey is one of FLINK_JOURNEYS: any FLINK tenant can register your origin, so aud alone is not enough
  • method is one you accept (prefill.allowed_methods, default ['eid']). eid is any eID provider route (ID Austria, SkIDentity or SK Authentigate); the claim does not say which one
  • reference is this session's (or the one you pass as the second argument), so a JWT copied from someone else's browser fails

Everything but the signature is an InvalidAssertion. Two failures are deliberately not a FlinkException, because a broken setup should fail loudly rather than refuse every applicant quietly: MissingConfiguration for an unset variable, and KeysUnavailable when FLINK's keys cannot be fetched (a wrong FLINK_URL, or an outage).

A journey with no identity step (email only, say) issues a JWT without method, and the prefill is refused. That is deliberate: nothing in such a JWT is identity-backed, the email address included, so it cannot stand in for a verified identity.

changed() returns the verified fields the submission changed or left out, as dotted keys; pass only: to compare a subset. It compares strings after trimming and Unicode NFC normalisation, and nothing more. Which changes you accept is your policy: docs/statamic.md locks names and birthdate and lets the address change.

Flink::reference() issues this session's reference on first use (@flinkReference in Blade), Flink::forgetReference() rotates it, so one journey run backs one submission. $prefill->id is the JWT's jti: the same on every retry of one release, so to be sure of one submission per release regardless of the session, accept each id once.

The JWT is personal data that stays verifiable after it expires: send it in a request body, verify it as soon as it arrives (it expires 5 minutes after the journey ends), and do not log or store it.

Verify any FLINK assertion

Every assertion FLINK signs verifies the same way; the caller always names the type it expects, so one type is never accepted as another (a results assertion never expires, so accepting it as a prefill would make it replayable forever):

use Mohemian\Flink\Assertions\PrefillAssertion;
use Mohemian\Flink\Assertions\ResultsAssertion;

$results = Flink::results($jwt);                         // flow-results+jwt, aud = FLINK_RESULTS_AUDIENCE
$results->results; $results->get('identity.givenName');

Flink::verify($jwt, ResultsAssertion::class);            // the same, for any Assertion subclass
Flink::verify($jwt, PrefillAssertion::class, ['reference' => $reference]); // override an expectation

Testing your app

Flink::fake() verifies against a key generated for the test, and signs assertions your config accepts, so your tests never reach FLINK:

$fake = Flink::fake();

$jwt = $fake->prefill(['givenName' => 'Erika']);            // bound to this session's reference
$this->post('/flink/prefill', ['prefill_jwt' => $jwt])->assertNoContent();

$fake->prefill(claims: ['method' => 'doc']);                 // override or drop (null) a claim
$fake->results(['identity' => ['givenName' => 'Erika']]);

Operations

  • php artisan flink:keys:clear forgets the cached keys, so the next verification fetches them.
  • php artisan about shows the FLINK settings.
  • Set flink.events to dispatch AssertionVerified and AssertionRejected for an audit log. The rejection carries the expected type, the reason and the exception class as strings, never the JWT or its claims, so a queued listener can take it.
  • flink.cache picks the store, key and lifetime of the cached keys; FLINK_JWKS_URL overrides where they come from.

Develop

composer install
composer test           # Pest
composer analyse        # PHPStan
composer format         # Rector, then Pint: rewrites the code in place
composer format:check   # what CI runs: fails on anything `format` would change

License

MIT

About

Verify FLINK results (ID Austria prefill) in a Laravel backend

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages