Skip to content

Latest commit

Β 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Embedded eeID β€” a working integration tutorial (PHP)

Run eeID authentication inside your own page, in an iframe, instead of redirecting the user away to the login server and back.

This repository is a complete, runnable integrator: ~1 file of PHP and a <script> tag. It is small on purpose β€” the point is that you can read the whole thing and see exactly which four HTTP calls make up an embedded integration, then reproduce them in any language.

The security property that matters: the OAuth2 authorization code never reaches the browser. Your backend creates the session with your client credentials, the browser only ever holds a single-use result_token, and your backend swaps that token for the code. A user with dev-tools open sees nothing they can replay.


What you get

In-frame Smart-ID, Mobile-ID, WebAuthn / passkeys, Veriff, ID-card (Web eID)
Leaves the frame, returns to it Smart-ID+ (app deeplink), and the OIDC methods β€” eIDAS, eParaksts, Freja, MojeID
Cookieless The session is keyed by an opaque token in the URL, not a cookie, so it survives third-party-cookie blocking
Themeable CSS custom properties: colours, density, radius, fonts β€” plus live setTheme()
i18n Locale chosen per session
Framing allowlist Only origins you register can frame the widget or receive its messages

The methods that leave the frame do so deliberately: a mobile app deeplink and a foreign identity provider both need a top-level browser navigation, so the widget hands the top window over and comes back to your page afterwards (see Methods that leave the frame).


How it works

Four calls. Two from your server, one from the browser, one from your server again.

 browser                     your backend                 eeID login server        eeID OAuth2 (Hydra)
    β”‚                             β”‚                            β”‚                     β”‚
    β”‚  GET /                      β”‚                            β”‚                     β”‚
    │────────────────────────────>β”‚                            β”‚                     β”‚
    β”‚                             β”‚ β‘  POST /api/embedded/sessions                    β”‚
    β”‚                             β”‚    (Basic client_id:secret)β”‚                     β”‚
    β”‚                             │───────────────────────────>β”‚                     β”‚
    β”‚                             β”‚                            β”‚ GET /oauth2/auth    β”‚
    β”‚                             β”‚                            │────────────────────>β”‚
    β”‚                             β”‚                            β”‚<─ login_challenge ──│
    β”‚                             β”‚<──── { session_token } ────│                     β”‚
    β”‚<── page with <eeid-widget> ─│                            β”‚                     β”‚
    β”‚                                                          β”‚                     β”‚
    β”‚  β‘‘ GET /embed/<session_token>   (no cookie)              β”‚                     β”‚
    │─────────────────────────────────────────────────────────>β”‚                     β”‚
    β”‚<── login form + CSP frame-ancestors ─────────────────────│                     β”‚
    β”‚                                                          β”‚                     β”‚
    β”‚  … user authenticates in-frame …                         β”‚                     β”‚
    β”‚                                                          β”‚ accept login+consentβ”‚
    β”‚                                                          │────────────────────>β”‚
    β”‚<── β‘’ postMessage eeid:success { resultToken } ───────────│<── code ────────────│
    β”‚                             β”‚                            β”‚                     β”‚
    β”‚  POST / { result_token }    β”‚                            β”‚                     β”‚
    │────────────────────────────>β”‚ β‘£ POST …/redeem (Basic)    β”‚                     β”‚
    β”‚                             │───────────────────────────>β”‚                     β”‚
    β”‚                             β”‚<──────── { code } ─────────│                     β”‚
    β”‚                             β”‚ POST /oauth2/token (Basic) ─────────────────────>β”‚
    β”‚                             β”‚<──────────── { id_token } ──────────────────────│
    β”‚<── signed in ───────────────│                            β”‚                     β”‚

Note what the browser never does: talk to the OAuth2 server. The login server performs the authorization request and the login/consent hops server-side, holding the OAuth2 server's CSRF cookies in a per-session jar. That is what makes the flow work when third-party cookies are blocked β€” and it is why there is no cookie to "log out" of later.


Before you start

You need an eeID service (an OAuth2 client) with three things configured:

  1. Client ID and secret β€” the same confidential client you would use for the redirect flow.
  2. Embedded mode enabled on the service.
  3. Your page's origin allowlisted. This is the step everyone misses. Without it the session call returns 403 Embedded mode is not enabled for this client. and the browser refuses to render the frame.

In the eeID Manager, on the service's edit form: tick Enable embedded widget, then add your origin under Embedded widget allowed origins, one per line β€” e.g. http://localhost:8081 for this demo, and your real origins for production.

Origins are validated as scheme://host[:port] only. A trailing path, or a wildcard like https://*.example.com, is rejected: a wildcard cannot be used as a postMessage target, so allowing it would force the widget to post to * and leak the result token to any framing page.


Run the demo

cp .env.example .env
#   EEID_CLIENT_ID=…
#   EEID_CLIENT_SECRET=…
#   EEID_REDIRECT_URI=…      a registered redirect_uri on that client
#   EEID_LOGIN_PUBLIC_URL=https://auth.eeid.ee
docker compose up --build

Open http://localhost:8081, pick a method, complete it β€” the panel on the right shows the decoded ID-token claims, obtained with no browser-side redirect to your site.

Running eeID locally rather than over the internet? Copy docker-compose.override.yml.example to docker-compose.override.yml β€” Compose merges it automatically, so docker compose up is unchanged. It joins your eeID stack's Docker network (pair it with EEID_LOGIN_INTERNAL_URL) and mounts its private CA. You need none of this against a hosted eeID.

EEID_REDIRECT_URI must be a registered redirect_uri on your client. The browser never navigates there in the embedded flow, but a real OAuth2 authorization request is started with it, and the same value is presented again at the token exchange, so it has to match exactly.


Integrating it in your own app

β‘  Create a session (server-side)

$response = http_post_json(
    "$loginServer/api/embedded/sessions",
    [
        'redirect_uri' => $redirectUri,   // registered on your client
        'scope'        => 'openid',
        'state'        => bin2hex(random_bytes(16)),   // you verify this later
        'nonce'        => bin2hex(random_bytes(16)),   // and this, in the ID token
        'locale'       => 'en',
        'return_url'   => current_page_url(),          // see "methods that leave the frame"
        'theme'        => ['color' => ['primary' => '#0b5fff']],
    ],
    basicAuth: [$clientId, $clientSecret]
);

// β†’ { "session_token": "…", "expires_in": 600, "allowed_methods": [...], "locales": [...] }

The client secret never leaves your server. The session_token is safe to put in the page: it is opaque, single-purpose and short-lived, and it only works from an allowlisted origin.

β‘‘ Mount the widget (in the page)

<script src="https://auth.eeid.ee/widget/eeid-widget.js"></script>

<eeid-widget
  base-url="https://auth.eeid.ee"
  session-token="<?= htmlspecialchars($sessionToken) ?>"
  height="420px"></eeid-widget>

The SDK is served by the login server itself β€” no npm install, no build step. It defines one custom element that frames the login UI, delegates the browser permissions the in-frame methods need (WebAuthn, camera), tracks the iframe height, and re-emits the widget's messages as ordinary DOM events.

β‘’ Handle the outcome (in the page)

const widget = document.querySelector("eeid-widget")

widget.addEventListener("eeid:success", async (e) => {
  widget.remove()                               // ← REQUIRED, see below
  await fetch("/signin", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ result_token: e.detail.resultToken }),
  })
  location.reload()
})

widget.addEventListener("eeid:error",  (e) => showError(e.detail.error))
widget.addEventListener("eeid:cancel", ()  => showChooserAgain())

β‘£ Redeem and exchange (server-side)

// Single-use, and bound to your client: a replay, or another client's attempt, gets 410 Gone.
$redeemed = http_post_json(
    "$loginServer/api/embedded/sessions/$resultToken/redeem",
    [],
    basicAuth: [$clientId, $clientSecret]
);
// β†’ { "code": "…", "state": "…" }

verify_state($redeemed['state']);      // must match the state you sent in β‘ 

// Ordinary OAuth2 from here β€” the same call your redirect integration already makes.
$tokens = http_post_form("$oauthServer/oauth2/token", [
    'grant_type'   => 'authorization_code',
    'code'         => $redeemed['code'],
    'redirect_uri' => $redirectUri,
], basicAuth: [$clientId, $clientSecret]);

$claims = verify_id_token($tokens['id_token'], nonce: $nonce);

From step β‘£ on, nothing is embedded-specific β€” it is the authorization-code flow you already know. If you have a redirect integration today, you are reusing its second half unchanged.


Three things integrators get wrong

1. You must take the iframe down yourself. On success the widget posts the result token and then deliberately stops β€” it never navigates the top window, because that is your decision, not ours. If you leave it mounted it sits on "Signing you in…" forever and a perfectly successful login looks hung. Remove or hide it in your eeid:success handler.

2. "Log out" is not an eeID call. The embedded flow is cookieless, and the OAuth2 server's login cookies live in a server-side jar destroyed with the session β€” there is no eeID session in the browser to end. Logging out means dropping the tokens you hold and creating a new embedded session, which authenticates from scratch. The demo's Log out button does exactly that: clears the claims, asks its own backend for a new session, and mounts a fresh widget.

3. Sessions expire (10 minutes by default). Create one when the user is about to sign in, not on every page render, and mint a fresh one rather than reusing a token after a long idle.


Methods that leave the frame

Smart-ID+ and the OIDC methods (eIDAS, eParaksts, Freja, MojeID) cannot complete inside an iframe: an app deeplink is only honoured from a top-level navigation, and a foreign identity provider refuses to be framed. For those, the widget takes over the top window and needs to know where to send the user back.

That is what return_url in step β‘  is for. Pass the URL of the page hosting the widget. When authentication finishes, the browser lands back on it with a token in the query string:

$sessionToken = $_GET['eeid_session_token'] ?? null;   // resume
if ($sessionToken === null) {
    $sessionToken = create_session(...)['session_token'];   // fresh
}

Mount the widget with that token and the flow finishes normally through eeid:success. Strip the parameter from the address bar (history.replaceState) as you consume it β€” it is single-use, so a reload or a Back would otherwise remount a dead session.

Omit return_url and those methods fall back to a plain top-level redirect to your redirect_uri?code=…, exactly like a classic redirect integration.


Theming

Two layers, both optional: a theme object at session creation (server-side, per session) and live updates from the page.

widget.setTheme({ colorScheme: "dark", color: { primary: "#059669" } })

Try the links at the top of the demo page β€” ?scheme=dark, ?density=compact, ?primary=%23e1261c, ?rows=solid, ?locale=et β€” each re-creates the session with a different theme so you can see one token restyle the whole form.

Why primary alone can look like nothing happened: color.primary paints submit buttons and outline/ghost variants. The first screen β€” the method chooser β€” has none of those; its rows take their colours from components.methodList.*. Use ?rows=tint / ?rows=solid in the demo to see that screen change.

And note what solid also does: it forces the row text to white. A saturated background token without its matching foreground token is how you end up with an unreadable widget β€” always move the pair together.


Verifying the security properties

Worth doing once in your own integration; each takes a minute.

Framing allowlist. Serve your page from an origin that is not allowlisted β€” e.g. http://127.0.0.1:8081 when you registered http://localhost:8081. The browser must refuse to render the frame, with a CSP frame-ancestors violation in the console.

Cookielessness. In Safari, enable Prevent cross-site tracking (or block all cookies) and reload. The widget must still load and complete. This is the whole reason the design is cookieless β€” it is the single most valuable thing to re-test on your own stack.

Message origin checks. The widget accepts messages only from its own iframe and its own origin, and posts only to allowlisted origins β€” never *. Your listener should be equally strict; see how the demo does it in index.php.

The code never leaks. Watch the network tab through a whole successful login: the browser sees a session_token and a result_token, never an authorization code and never your client secret.


Troubleshooting

Symptom Cause
403 Embedded mode is not enabled for this client. Embedded not ticked on the service, or no valid allowlisted origin
Frame stays blank; console shows a CSP frame-ancestors violation Your page's origin is not in the allowlist (check scheme, host and port β€” localhost β‰  127.0.0.1)
401 Invalid client credentials. Wrong client id/secret, or the client lives in the other eeID environment (Test vs Production are separate stacks)
410 Gone from redeem The result token was already redeemed, expired, or belongs to another client β€” it is strictly single-use
Widget shows "Signing you in…" forever You did not remove the iframe on eeid:success (see gotcha 1)
Smart-ID+ opens a page saying the app could not be launched The deeplink was followed inside the frame β€” pass return_url so it runs top-level
Session works once, then 404 after reload Session tokens are single-use per flow; create a new one

Reference

Endpoints (all on the eeID login server, all Basic-authenticated with your client credentials except the iframe URL):

POST /api/embedded/sessions create a session β†’ { session_token, expires_in, allowed_methods, locales }
GET /embed/{session_token} the iframe URL (the SDK builds this for you)
POST /api/embedded/sessions/{result_token}/redeem β†’ { code, state }, single-use
POST /oauth2/token standard OAuth2 code exchange, on the eeID OAuth2 server

<eeid-widget> attributes: base-url (required), session-token (required), height, theme (JSON), title, origin (only if the frame is served from another origin).

Methods: setTheme(partial) β€” deep-merges and applies live.

Events (DOM CustomEvents, data in detail): eeid:ready, eeid:resize, eeid:success (detail.resultToken, detail.state), eeid:error (detail.error, detail.errorDescription), eeid:cancel, eeid:expand / eeid:collapse (a method asked for a full-page overlay; the SDK handles it, listen only if you have your own layout to adjust).


Files

index.php the entire integration β€” session creation, the page, redeem, token exchange, claims
.env.example every setting, documented
docker-compose.yml one PHP container on port 8081
docker-compose.override.yml.example optional: talk to an eeID running locally in Docker

Demo code, deliberately minimal: no framework, no session store, no persistence. Read it, copy the four calls, throw the rest away.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages