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 `