From 587d14a05100446ea08ebbb4bc19cd081f1210db Mon Sep 17 00:00:00 2001 From: "deploy.sh" <> Date: Tue, 8 Sep 2026 10:59:38 +0300 Subject: [PATCH] Document the embedded sign-in form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Embedded sign-in" section between Authentication and Identification: the widget runs the authentication in an iframe on the integrator's own page instead of redirecting, using the same service, credentials, scopes and ID token — only the start of the flow differs. Covers when NOT to use it (public/PKCE clients cannot: creating a session needs the client secret), enabling it and allowlisting origins in eeID manager, the three API calls with parameters and samples, the widget element with its attributes and events, redeeming the single-use result token, return_url for the methods that must leave the frame, theming, the error table, rate limits and the test environment. Two things integrators cannot guess are called out explicitly: the code never reaches the browser (their backend redeems a single-use token for it), and they must take the iframe down on eeid:success or a completed sign-in looks hung. Links the PHP example at github.com/internetee/eeid_php_embedded_demo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GP9ZnyKyjjFfBmv3evRGV3 --- source/index.html.md | 358 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) diff --git a/source/index.html.md b/source/index.html.md index 58b6f87..549a8f6 100644 --- a/source/index.html.md +++ b/source/index.html.md @@ -1461,6 +1461,364 @@ Start up rails server and point browser to [http://localhost:3000](http://localh bin/dev ``` +# Embedded sign-in + +The embedded sign-in form (the *eeID widget*) runs the authentication inside an iframe on your +own page, instead of redirecting the user to eeID and back. The user never leaves your site. + +Everything else stays the same: the same service, the same client credentials, the same scopes +and the same ID token. Only the beginning of the flow differs — instead of sending the browser +to the [authentication request](#authentication-request) URL, your backend asks eeID for a +session and your page frames it. + +**The authorization code never reaches the browser.** Your backend creates the session with your +client credentials, the widget hands your page a single-use `result_token`, and your backend +exchanges that token for the code. A user with developer tools open sees nothing they can replay. + +A complete, runnable integration in PHP is published at +[github.com/internetee/eeid_php_embedded_demo](https://github.com/internetee/eeid_php_embedded_demo). + +## Should you use it? + +Use the embedded form when you want the sign-in to feel part of your own page, and you have a +backend that can hold a client secret. + +Keep the classic [redirect flow](#eeid-authentication) when your client is public (a single-page +app or a mobile application using [PKCE](#pkce-proof-key-for-code-exchange)), or when you cannot +run the two server-side calls the embedded form requires. The embedded form is **not** available +to public clients: creating a session requires your client secret. + +Both flows can be enabled on the same service at the same time. + +## Requirements + +- A registered and approved `Authentication` service — see [Creating a new service](#creating-a-new-service) +- Its `Client ID` and `Secret`, used from your backend only +- **Embedded mode enabled** on the service, with your page's origin allowlisted (below) + +## Enabling embedded mode + +In [eeID manager](https://eeid.ee), open your service and edit it: + +1. Tick **Enable embedded widget**. +2. In **Embedded widget allowed origins**, add every origin that will frame the widget, one per + line — for example `https://www.example.com`. +3. Save. + +Only the listed origins may frame the widget or receive its messages. A page served from any +other origin is refused by the browser, and session creation is rejected. + +An origin is a scheme, a host and an optional port — nothing else: + +* `https://www.example.com` — correct +* `https://www.example.com:8443` — correct +* `https://www.example.com/signin` — rejected, a path is not part of an origin +* `https://*.example.com` — rejected, wildcards cannot be used to address a browser window + +`http://` is accepted only for local development origins such as `http://localhost:8081`. + + + +## How it works + +Four steps. Two of them are server-to-server calls from your backend, one is the widget in your +page, and the last is the ordinary OAuth2 token request you already make today. + +1. Your backend calls **`POST /api/embedded/sessions`** with your client credentials and receives + a `session_token`. +2. Your page loads the widget script and renders `` with that token. The user picks + a method and authenticates inside the iframe. +3. The widget tells your page it succeeded, handing over a single-use `result_token`. +4. Your backend calls **`POST /api/embedded/sessions/{result_token}/redeem`** to obtain the + OAuth2 `code`, then exchanges it at the [identity token request](#identity-token-request) exactly as in + the redirect flow. + +The browser never talks to the OAuth2 server: eeID performs the authorization request and the +login/consent steps server-side. That is what allows the flow to work when third-party cookies +are blocked. + +## Creating a session + +> Create a session + +```shell +curl -X POST https://auth.eeid.ee/api/embedded/sessions \ + -u "$EEID_CLIENT_ID:$EEID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uri": "https://www.example.com/auth/callback", + "scope": "openid", + "state": "f3b2c3e7f4cf0bed3a783ed6ece617e3", + "nonce": "9a1f4b0c2d8e6f37", + "locale": "en", + "return_url": "https://www.example.com/signin" + }' +``` + +> Response + +```json +{ + "session_token": "gW7s8x1Qm2...", + "expires_in": 600, + "allowed_methods": null, + "locales": ["en", "et", "ru"] +} +``` + +URL: `POST https://auth.eeid.ee/api/embedded/sessions` + +Authentication: HTTP Basic, with your `Client ID` as the user name and your `Secret` as the +password. **This call must be made from your backend.** Never expose the secret to a browser. + +Required parameters: + +- `redirect_uri` - one of the redirection URLs registered on the service. The browser is not sent + there in the embedded flow, but a real authorization request is started with it and the same + value is presented again at the token request, so it must match exactly. + +Optional parameters: + +- `scope` - authentication scope, defaults to `openid`. Must correspond to the scope selected for + the service. See [Authentication scope](#authentication-scope) +- `state` - security code against false request attacks. It is returned to you by the redeem call, + where you verify it. See [Protection](#protection) +- `nonce` - unique parameter which helps to prevent replay attacks. Verify it in the ID token +- `locale` - user interface language: `et`, `en` or `ru`. `ui_locales` is accepted as an alias +- `return_url` - the page hosting the widget. Required for the authentication methods that must + leave the iframe — see [Methods that leave the frame](#methods-that-leave-the-frame). Its origin + must be on the service's allowlist +- `theme` - a JSON object styling the widget, see [Theming](#theming) + +Response fields: + +- `session_token` - the opaque token the widget is mounted with. It is not a credential of yours + and is safe to render into the page: it works only from an allowlisted origin +- `expires_in` - seconds the session remains valid (600 by default). Create a session when the + user is about to sign in, not on every page render +- `allowed_methods` - the authentication methods the widget will offer, or `null` for all methods + configured on the service +- `locales` - the languages the widget supports + +## Embedding the widget + +> Load the SDK and mount the widget + +```html + + + +``` + +> React to the outcome + +```javascript +const widget = document.querySelector("eeid-widget") + +widget.addEventListener("eeid:success", async (event) => { + widget.remove() // required, see the note below + + await fetch("/signin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ result_token: event.detail.resultToken }) + }) + + location.reload() +}) + +widget.addEventListener("eeid:error", (e) => showError(e.detail.error)) +widget.addEventListener("eeid:cancel", () => showChooserAgain()) +``` + +The SDK is served by eeID itself — there is nothing to install and no build step. It defines one +custom element that frames the sign-in form, delegates the browser permissions the framed methods +need, tracks the iframe height, and re-emits the widget's messages as ordinary DOM events. + +Attributes of ``: + +- `base-url` (required) - `https://auth.eeid.ee`, or `https://test-auth.eeid.ee` for a service in + the `Test` environment +- `session-token` (required) - the token from the create call +- `height` - initial height, for example `420px`. The widget resizes itself afterwards +- `theme` - a JSON theme, applied without creating a new session +- `title` - accessible title of the iframe + +Method: + +- `setTheme(partial)` - merges and applies a theme at runtime, for example when your page switches + to dark mode + +Events, dispatched on the element with their data in `detail`: + +- `eeid:ready` - the form is loaded +- `eeid:resize` - the content height changed; the SDK has already applied it +- `eeid:success` - authentication finished. `detail.resultToken` is the single-use token to redeem, + `detail.state` is the `state` you supplied +- `eeid:error` - authentication failed. `detail.error` and `detail.errorDescription` carry the + OAuth2 error +- `eeid:cancel` - the user cancelled +- `eeid:expand` / `eeid:collapse` - a method asked for the whole viewport (identity verification + does this). The SDK handles it; listen only if your own layout needs to react + + + +## Redeeming the result + +> Redeem the result token, then exchange the code + +```shell +curl -X POST https://auth.eeid.ee/api/embedded/sessions/$RESULT_TOKEN/redeem \ + -u "$EEID_CLIENT_ID:$EEID_CLIENT_SECRET" +``` + +> Response + +```json +{ + "code": "71ed5797c3d957817d31", + "state": "f3b2c3e7f4cf0bed3a783ed6ece617e3" +} +``` + +URL: `POST https://auth.eeid.ee/api/embedded/sessions/{result_token}/redeem` + +Authenticated with the same client credentials as the create call, and made from your backend. + +The result token is **single-use and bound to your client**: the first valid call returns the code +and destroys the session. A replay, an expired token, or a token belonging to another client is +answered with `410 Gone` and nothing else — an attacker who intercepts a result token cannot learn +whether it ever existed. + +Verify that the returned `state` matches the one you sent, then exchange the `code` with an +[identity token request](#identity-token-request), using the same `redirect_uri` you used when creating the session. +From that point the flow is identical to the redirect flow, including verification of the ID +token and its `nonce`. + +## Methods that leave the frame + +Smart-ID+ and the cross-border methods (eIDAS, eParaksts, Freja, MojeID) cannot complete inside an +iframe: a mobile app link is only honoured from a top-level navigation, and a foreign identity +provider refuses to be framed. For those methods the widget takes over the whole window and then +returns to your page. + +That is what `return_url` is for. Pass the URL of the page hosting the widget when you create the +session. When the user comes back, that URL carries a query parameter: + +> Resuming after a method that left the frame + +```php + with $sessionToken as usual +``` + +Mount the widget with that token and the flow finishes normally through `eeid:success`. Strip the +parameter from the address bar as you consume it — it is single-use, so a reload would otherwise +try to resume a session that is already spent. + +If you omit `return_url`, those methods fall back to a plain top-level redirect to your +`redirect_uri?code=…`, exactly like the classic redirect flow. + +## Theming + +> A theme covering most needs + +```json +{ + "preset": "light", + "colorScheme": "system", + "color": { "primary": "#0B5FFF", "onPrimary": "#FFFFFF" }, + "typography": { "fontFamily": "\"Inter\", system-ui, sans-serif" }, + "shape": { "radiusButton": "8px" }, + "branding": { "showFooter": false } +} +``` + +The widget is styled with a JSON theme, supplied either as the `theme` parameter when creating the +session, as the `theme` attribute on the element, or at runtime with `setTheme()` — for example +when your page switches to dark mode. + +The main groups are `preset`, `colorScheme` (`light`, `dark`, `system`), `density` +(`comfortable`, `compact`), `color`, `typography`, `shape`, `spacing`, `components`, `layout`, +`branding` and `messages`. Unknown keys are rejected rather than ignored, so a typo is reported +when the session is created instead of silently doing nothing. + +Note that the first screen the user sees — the list of authentication methods — takes its colours +from `components.methodList`, not from `color.primary`, which paints buttons. Changing only the +primary colour therefore looks like nothing happened until a method is selected. + +For a stylesheet of your own, `advanced.themeCssUrl` loads one extra CSS file inside the widget. +It must be served from an origin that is already on the service's allowlist. + +## Errors + +| Status | Meaning | +| ------ | ------- | +| `400 Bad Request` | A parameter is invalid, for example a `return_url` whose origin is not allowlisted | +| `401 Unauthorized` | The client id or secret is wrong, or the service belongs to a different environment | +| `403 Forbidden` | Embedded mode is not enabled for this client, or no valid origin is allowlisted | +| `410 Gone` | The result token is invalid, already redeemed, or expired | +| `422 Unprocessable Entity` | The theme did not validate, or a required parameter is missing | +| `429 Too Many Requests` | Rate limited, see below. `Retry-After` gives the seconds to wait | +| `502 Bad Gateway` | eeID could not reach its authorization server. Retry later | + +Errors that happen *during* authentication reach your page as an `eeid:error` event rather than as +a status code — the session was created successfully, the authentication itself failed. + +## Rate limits + +The embedded API is rate limited per client and per source address. The limits are set well above +normal traffic and exist to stop credential guessing: **failed** authentications are counted far +more strictly than ordinary calls, and a successful call clears that count. + +If you receive `429 Too Many Requests`, wait the number of seconds given in the `Retry-After` +header before retrying. Repeated `401`s from a misconfigured secret are the usual way to reach it, +so fix the credentials rather than retrying in a loop. + +## Test environment + +A service in the `Test` environment uses `https://test-auth.eeid.ee` for all three URLs — the +session API, the widget script and the `base-url` attribute — and the matching token endpoint. +Test authentications are free. A service exists in exactly one environment at a time, so use the +host that matches the service your credentials belong to. + +## Example application + +A complete integration, small enough to read in one sitting, is published at +[github.com/internetee/eeid_php_embedded_demo](https://github.com/internetee/eeid_php_embedded_demo). + +It is a single PHP file plus a `