Search by

mohemian / flink-sdk-php

markus.buergler@mohemian.com

Verify FLINK's signed assertions (eID prefill, flow results) in a Laravel backend.

Package info

github.com/mohemian/flink-sdk-php

pkg:composer/mohemian/flink-sdk-php

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v1.1.0 2026-11-11 12:04 UTC

README

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 12 or 13, ext-intl, ext-openssl.

FLINK_TENANT_ID=…   # the console's journey page, "Start & integrate"
FLINK_JOURNEY_ID=…  # the journey's public id, same place; several comma-separated
# FLINK_API_HOST=https://flink-services.mohemian.rocks  (default; FLINK_URL still works, deprecated)
# FLINK_AUDIENCE=https://www.example.at            (default: APP_URL's origin)
# FLINK_API_KEY=flk_…                              (to fetch results: the journey's API key, from its Start tab)
# or, instead of the API key:
# FLINK_RESULTS_AUDIENCE=…                         (only for results: your backend's client id)
# FLINK_CLIENT_KEY="-----BEGIN PRIVATE KEY-----…"  (only to fetch results: php artisan flink:client-key)
# FLINK_CLIENT_KEY_ID=…                            (the kid FLINK registered its public half under)

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_API_HOST, 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_JOURNEY_ID: 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_API_HOST, 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. A field submitted both nested and as a literal dotted key (address[street] and address.street), or as a literal dotted key beside a nested array under the same path (address.street and address[street][]), counts as changed. 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);                         // flink-result+jwt, aud = FLINK_RESULTS_AUDIENCE
$results->get('givenName'); $results->get('address.locality'); $results->source('givenName');
$results->attributes; $results->sources; $results->journeyId;

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

A session's result

Fetch a completed session's result and verify it in one call:

use Mohemian\Flink\Exceptions\ResultUnavailable;

try {
    $results = Flink::fetchResult($sessionId);
} catch (ResultUnavailable $e) {
    // $e->status 409: not completed yet, $e->sessionStatus says how far it is ('started', 'aborted').
    // 401 a client id or key FLINK does not hold, 400 a grant this client may not use,
    // 403 a token bound to another tenant (a wrong FLINK_TENANT_ID), a missing scope, or a session-bound token;
    // 404 a session this client may not read. 0 or 5xx an outage. Never carries the token or the key.
}

Your backend calls FLINK as its own client, bound to your tenant (GET /client/v1/tenants/{tenantId}/sessions/{sessionId}/results, FLNK-861).

The simplest way to prove itself is a journey API key (FLNK-859): create it on the journey's Start tab in the console and set it as FLINK_API_KEY. The key is bound to your tenant and that one journey, and reads the result of any session of it, whoever started it (your page, the flow link, a QR code or the app); test runs and other journeys' sessions answer 404. It is sent as X-Api-Key to FLINK's token endpoint only. FLINK signs each result for the key's own subject, apikey:<key id>, which the package reads from the key's token and keeps, so FLINK_RESULTS_AUDIENCE can stay unset; set it to that subject if a kept result should verify before the package ever asked FLINK for a token. Revoking the key in the console stops it on the next request (401). Set the key or the client key below, not both.

Or it proves itself with private_key_jwt: a 60-second assertion signed with your private key, FLINK_CLIENT_KEY (EC P-256, or RSA of 2048 bits or more registered as RS256), so FLINK holds only the public half and no shared secret exists. php artisan flink:client-key <kid> makes the pair: the private key goes into your env with its kid as FLINK_CLIENT_KEY_ID, the public key set goes to FLINK, which registers it for your client. The client token is cached in flink.cache's store until shortly before it expires. A failure to hand the result over is a ResultUnavailable, deliberately not a FlinkException: nothing was verified, so nothing was found invalid.

With an API key you can also read a session's state, without its results, to see how far it got:

$session = Flink::fetchSession($sessionId);  // any session of the key's journey
$session->status;        // 'provisioned', 'started', 'completed' or 'aborted' (final); tolerate others
$session->steps;         // each: id, completed, startedAt, completedAt, declinedAt (null when not)
$session->createdAt; $session->updatedAt; $session->completedAt;

It throws ResultUnavailable like fetchResult() (404 for a session of another journey, a test run, or none at all), and MissingConfiguration without FLINK_API_KEY: FLINK lets only a journey API key read a session it did not run. A session id is not a secret, so decide who may ask your backend for which session before you call either method.

To let a person pick their country and eID on your own page before any FLINK screen, read the journey's countries (FLNK-1181; needs flink-services with it). It starts no session. FLINK requires the flow-session:write scope for it, so a private_key_jwt client must be registered with that scope. A results-only client lacks it and gets 403:

$read = Flink::fetchCountries($journey);   // the journey's public id
foreach ($read->countries as $country) {   // the journey's order, its primary market first
    $country->country;                     // 'se', or a test market 'se-test'
    $country->eids;                        // each: id, label (per locale, always 'en'), logoUri, kind
}
$read->countryLabels;                      // FLINK's own country names, keyed 'XA'; otherwise use your locale data

It is what FLINK's own web flow offers for the journey: every country it covers, each with the eIDs a session there offers (none for a document-only country). FLINK can withdraw an eID without the journey changing, so read it when you render the choice. It throws ResultUnavailable like fetchResult(): 404 for a journey that does not exist or that your credential may not read (an API key reads only its own journey, a client only its tenant's).

Fetched some other way, FLINK answers { "signedResult": "<jwt>" }, and Flink::results() verifies that JWT: ES256 only (the same JWKS serves the prefill's RS256 key, which is refused here), header typ = flink-result+jwt, iss = FLINK_API_HOST, aud is or contains FLINK_RESULTS_AUDIENCE (FLINK signs each result for the client id that fetched it), tenantId is yours, journeyId is one of FLINK_JOURNEY_ID (aud names no journey, so this is what keeps a result from your other journey out), and ver = 1. Pass the session you fetched it for, Flink::results($jwt, $sessionId), and a result for another session is refused too. It has no exp: it records a fact, so a result you keep stays verifiable.

Its attributes are flat, under the EUDI PID names (givenName, birthdate, address.locality, personalAdministrativeNumber, …), and include values FLINK or a provider derived (emailAddressVerified, sanctionsMatchFound, livenessPassed, …). Each is { value, source }, where source is a urn:flink:source:* identifier keying sources, the provenance. Every attribute is optional: absent means not handed over, never false. Ignore names you do not know; new ones are added without a version bump. An ageOver* FLINK derived from a verified birthdate, rather than read from the issuer, also carries derivedAsOf, the day it holds as of: $results->derivedAsOf('ageOver18') returns it, or null for an issuer's own value.

Send a person straight to their eID

Needs flink-services with FLNK-1182 (mohemian/flink-services#1483 and #1486), and a credential with the flow-session:write scope: a journey API key has it; a private_key_jwt client registered for results only gets 403, as for fetchCountries(). Your page asks for the person's country and eID (fetchCountries()), your backend starts a session there, and the person follows a FLINK link straight to that eID's login, with no FLINK screen. FLINK runs the journey's remaining steps itself (today only a sanctions check qualifies) and sends the person to the journey's return link. Your backend then confirms the return and fetches the result:

use Mohemian\Flink\Exceptions\SessionRefused;

// POST /verify/start   (the person is logged in to your site)
try {
    $session = Flink::startSession($journey, $country, $eidEntry, $request->ip(), $request->userAgent());
} catch (SessionRefused $e) {
    // $e->reason: 'unknown_country', 'unknown_eid_entry', 'eid_entry_not_redirect' (e.g. Swedish BankID),
    // 'journey_needs_interaction' ($e->step names the step), 'no_return_link', 'journey_not_runnable'
    return back()->withErrors(['eid' => 'This eID cannot be used here. Please choose another.']);
}
$request->user()->update(['flink_session' => $session->sessionId]);  // keep it with your user
return redirect()->away($session->url);

// GET /flink-return?flink_return_code=…   (your journey's return link)
$sessionId = $request->user()->flink_session;                      // never from the URL
if (Flink::completeReturn($sessionId, (string) $request->query('flink_return_code'))) {
    $results = Flink::fetchResult($sessionId);                     // givenName, birthdate, sanctionsMatchFound, …
}

Confirm only your own user's session. Look up the session id you stored for the user logged in on the return request; never take it from the URL. A link can be forwarded, and whoever opens it signs in with their own eID: the return code reaches only the browser that finished the login, so redeeming it only for the session your logged-in user started is what stops someone from sending their link to another person and receiving that person's identity. FLINK cannot check this for you.

When the person comes back where you do not know them. The return link must find your login cookie on a cross-site redirect from FLINK, so it needs SameSite=Lax (Laravel's default), not strict. A person who finished in another browser (an in-app browser, a QR code on another device) arrives without your session: log them in there before confirming, or start a new session. The code lives five minutes and is issued once.

Pass clientReferenceId: $order->id to tag the session with your own id (an order, a case, a customer; FLNK-1279). FLINK signs it into the result, and $results->clientReferenceId reads it back, with $results->clientReferenceSource saying where FLINK got it: api (your backend, as here), link (a flow link's query parameter) or embed (the browser SDK's reference). Match a result to your record on it only when the source is api, which FLINK says only to the client that started the session. It is null for a session started without one, and FLINK does not check that it is unique. It is up to 128 characters from A-Z a-z 0-9 . _ : - (anything else throws InvalidArgumentException before FLINK is called), and holds no personal data. A flow link takes the same value as its client_reference_id query parameter. This needs flink-services #1541, and links carry the reference only once flink-web #463 is merged. A value in a link is not authenticated: whoever holds the link can change it, so treat it as an unverified hint, never as proof of who started the session. Bind your record on sessionId, or on a reference whose source is api, never on a link or embed value.

Once your start page shows the journey's applicant notice, attest it on the start with notice: ['revision' => …, 'locale' => …]: the revision FLINK served and the BCP 47 locale you showed it in. FLINK does not require it yet (FLNK-1184).

startSession() throws SessionRefused with FLINK's reason, or ResultUnavailable (404 a journey your credential may not start, 429 too many starts, 403 a client without flow-session:write or one that is not a relying party's backend). The link opens for 15 minutes, to retry a failed or cancelled login, and never once the eID has succeeded. completeReturn() is true when FLINK released the result (again on a reload) and false for a wrong, expired or foreign code (start a new session). It throws ResultUnavailable when FLINK could not be asked: keep the code and let the person retry the same return URL. Until then, fetchResult() throws ResultUnavailable with status 409 and a reason: awaiting_return (confirm first), eid_failed (with eidError; opening the link again retries), sanctions_unavailable, return_expired or link_expired. Decide from sessionStatus alone whether to ask again: aborted is final. FLINK deletes the person's data from a session whose return was never confirmed.

Let the person choose on FLINK's screens (hosted start)

Needs flink-services with hosted starts turned on (FLNK-1272, mohemian/flink-services#1535); until then FLINK refuses the start with hosted_mode_unavailable. Leave out the eID, or the country too, and the person chooses on FLINK's own screens, while you still get the session id and a link, so the result and any webhook connect to your user:

$session = Flink::startSession($journey, clientIp: $request->ip(), userAgent: $request->userAgent());  // person picks the country
$session = Flink::startSession($journey, 'at');                                                        // person picks the eID

Store $session->sessionId with your user and send them to $session->url, exactly as above. Differences from the direct start:

  • The link opens once, on a landing page that opens nothing until the person's browser continues from it, so a forwarded link or a link preview cannot use it. It stays openable for 15 minutes (expiresAt).
  • The journey runs as it does from a journey link, so journey_needs_interaction and eid_entry_not_redirect never apply. The person must finish, and your backend redeem the return, within hostedDeadlineAt (with returnsWithCode, from Flink::fetchSession()): at most 8 hours after the start, earlier when FLINK's idle-session cutoff is shorter.
  • When the person finishes, they come back to your return link with a flink_return_code; confirm it with completeReturn() and fetch the result as above. A person who abandons a hosted session does not come back at all: your backend sees it started, and after the deadline fetchResult() answers aborted, reason link_expired (return_expired if a code was issued and not redeemed). (Only a direct start can return with no code: completeReturn() then answers false without asking FLINK, and fetchResult() says why. Show your own "not verified" page unless the session is completed.)
  • $notice is ignored (FLINK shows its own notice). A person who finishes on another device after a hand-off gets no return: your backend sees awaiting_return, then link_expired at the deadline.
  • New SessionRefused reasons: hosted_mode_unavailable, country_required (an eID without a country), and notice_required or consent_required for a journey that requires the person's notice or consent on some route (FLINK cannot collect it when the person picks the route). consent_needs_eid_entry is FLINK's refusal of versioned consent capture on a hosted start, which this package does not send.
// GET /flink-return?flink_return_code=…   (hosted or direct)
$sessionId = $request->user()->flink_session;                      // never from the URL
$code = $request->query('flink_return_code');
if (is_string($code) && Flink::completeReturn($sessionId, $code)) {
    $results = Flink::fetchResult($sessionId);
} else {
    // No code (a direct start that did not complete): see fetchResult()'s sessionStatus;
    // a wrong or expired code: start a new session.
}

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(['givenName' => ['value' => 'Erika', 'source' => 'urn:flink:source:eid:oidc']]);

$fake->serveResults(['givenName' => ['value' => 'Erika', 'source' => 'urn:flink:source:eid:oidc']]);
Flink::fetchResult($sessionId);                               // answered without FLINK, no key needed

Upgrading from 1.0 to 1.1

1.1 (FLNK-1272) is backwards compatible; nothing to change. Flink::startSession() now takes the country and the eID as optional, so the person can choose on FLINK's screens (see "Let the person choose on FLINK's screens"), Flink::completeReturn() takes a null code (a direct start that did not complete returns the person with none), and Flink::fetchSession() reports returnsWithCode and hostedDeadlineAt. The same tag also ships FLNK-1279: Flink::startSession() takes clientReferenceId, and ResultsAssertion reads it back (see "Send a person straight to their eID"). Positional calls with a country and an eID work as before. A hosted start needs flink-services with hosted starts turned on; until then FLINK refuses it with hosted_mode_unavailable.

Upgrading from 0.4 to 1.0

1.0 renames FLINK_JOURNEYS to FLINK_JOURNEY_ID, the name the console's journey page and FLINK's backend guides use. FLINK_JOURNEYS is no longer read: rename it in your .env. It still takes several journeys, comma-separated. A config/flink.php you published before 1.0 still reads FLINK_JOURNEYS under prefill.journeys: change that env() call too, or overwrite it with php artisan vendor:publish --tag=flink-config --force (flink:install leaves an existing file alone).

1.0 also renames FLINK's host to the API host (FLNK-1191): FLINK_URL becomes FLINK_API_HOST, and the config key url becomes api_host. Nothing breaks: FLINK_URL is still read when FLINK_API_HOST is unset, and so is the url key of a config/flink.php you published earlier, but both are deprecated and will go in a later release. Rename the variable in your .env, and in a published config replace 'url' => env('FLINK_URL', …) with 'api_host' => env('FLINK_API_HOST', env('FLINK_URL')). php artisan about now shows the host it resolved as "API host".

Upgrading from 0.2

0.3 adds FLINK_API_KEY: Flink::fetchResult() can authenticate with a journey API key instead of FLINK_CLIENT_KEY (needs flink-services with FLNK-859), and Flink::fetchSession() reads a session's state with it. Nothing changes for a client key. To switch to an API key, set FLINK_API_KEY and remove FLINK_CLIENT_KEY and FLINK_RESULTS_AUDIENCE (the audience is the key's subject then; a leftover client id throws MissingConfiguration). A custom binding that constructs ResultClient itself: its client id, key and kid are nullable now, and the API key is a new last argument.

Upgrading from 0.1

0.2 follows flink-services' results contract (FLNK-861); the prefill is unchanged.

  • New: Flink::fetchResult($sessionId) fetches and verifies a session's result with your own key (FLINK_CLIENT_KEY, FLINK_CLIENT_KEY_ID; php artisan flink:client-key makes them). A client that reads results authenticates to FLINK with its key only; a client secret no longer works for it.

  • Laravel 10 and 11 are no longer supported: 0.2 needs Laravel 12 or 13.

  • An archived flow-results+jwt no longer verifies under 0.2: it has no class for that type, so Flink::results() on a result you stored under 0.1 throws InvalidAssertion. FLINK keeps the old RS256 keys in its JWKS, so to re-verify archives stay on 0.1, or subclass Assertion (RS256, tenant_id) and pass it to Flink::verify().

  • Flink::results() verifies a flink-result+jwt (ES256) instead of a flow-results+jwt: read attributes, sources, journeyId and get('givenName') instead of results and get('identity.givenName'). Set FLINK_RESULTS_AUDIENCE to your backend's client id, and FLINK_JOURNEY_ID to the journeys whose results you accept.

  • KeySet::key() takes the expected algorithm as a second argument: a custom key set must return a key of exactly that algorithm.

  • Verifier::claims() takes the assertion class (ResultsAssertion::class) instead of its typ.

  • Flink::fake()->results() takes attributes (['givenName' => ['value' => …, 'source' => …]]).

  • ResultsAssertion::toArray() and jsonSerialize() return the attributes (each { value, source }), not results.

  • FakeKeySet is built from a list of JWKs rather than one, and key() takes the algorithm.

Operations

  • php artisan flink:keys:clear forgets the cached keys, so the next verification fetches them.
  • Telescope (or any watcher of the HTTP client) records request headers and response bodies: hide the X-Api-Key header, access_token and signedResult there, your API key, a live token and the applicant's data.
  • php artisan flink:client-key <kid> makes the key pair Flink::fetchResult() uses. To rotate, register the new public key with FLINK beside the old one, switch your env, then have the old one removed.
  • 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