@@ -1461,6 +1461,364 @@ Start up rails server and point browser to [http://localhost:3000](http://localh
14611461bin/dev
14621462` ` `
14631463
1464+ # Embedded sign-in
1465+
1466+ The embedded sign-in form (the *eeID widget*) runs the authentication inside an iframe on your
1467+ own page, instead of redirecting the user to eeID and back. The user never leaves your site.
1468+
1469+ Everything else stays the same : the same service, the same client credentials, the same scopes
1470+ and the same ID token. Only the beginning of the flow differs — instead of sending the browser
1471+ to the [authentication request](#authentication-request) URL, your backend asks eeID for a
1472+ session and your page frames it.
1473+
1474+ **The authorization code never reaches the browser.** Your backend creates the session with your
1475+ client credentials, the widget hands your page a single-use `result_token`, and your backend
1476+ exchanges that token for the code. A user with developer tools open sees nothing they can replay.
1477+
1478+ A complete, runnable integration in PHP is published at
1479+ [github.com/internetee/eeid_php_embedded_demo](https://github.com/internetee/eeid_php_embedded_demo).
1480+
1481+ # # Should you use it?
1482+
1483+ Use the embedded form when you want the sign-in to feel part of your own page, and you have a
1484+ backend that can hold a client secret.
1485+
1486+ Keep the classic [redirect flow](#eeid-authentication) when your client is public (a single-page
1487+ app or a mobile application using [PKCE](#pkce-proof-key-for-code-exchange)), or when you cannot
1488+ run the two server-side calls the embedded form requires. The embedded form is **not** available
1489+ to public clients : creating a session requires your client secret.
1490+
1491+ Both flows can be enabled on the same service at the same time.
1492+
1493+ # # Requirements
1494+
1495+ - A registered and approved `Authentication` service — see [Creating a new service](#creating-a-new-service)
1496+ - Its `Client ID` and `Secret`, used from your backend only
1497+ - **Embedded mode enabled** on the service, with your page's origin allowlisted (below)
1498+
1499+ # # Enabling embedded mode
1500+
1501+ In [eeID manager](https://eeid.ee), open your service and edit it :
1502+
1503+ 1. Tick **Enable embedded widget**.
1504+ 2. In **Embedded widget allowed origins**, add every origin that will frame the widget, one per
1505+ line — for example `https://www.example.com`.
1506+ 3. Save.
1507+
1508+ Only the listed origins may frame the widget or receive its messages. A page served from any
1509+ other origin is refused by the browser, and session creation is rejected.
1510+
1511+ An origin is a scheme, a host and an optional port — nothing else :
1512+
1513+ * `https://www.example.com` — correct
1514+ * `https://www.example.com:8443` — correct
1515+ * `https://www.example.com/signin` — rejected, a path is not part of an origin
1516+ * `https://*.example.com` — rejected, wildcards cannot be used to address a browser window
1517+
1518+ ` http://` is accepted only for local development origins such as `http://localhost:8081`.
1519+
1520+ <aside class="notice">
1521+ Origins are compared exactly. <code>https://example.com</code> and <code>https://www.example.com</code>
1522+ are different origins, and so are <code>http://localhost:8081</code> and <code>http://127.0.0.1:8081</code>.
1523+ </aside>
1524+
1525+ # # How it works
1526+
1527+ Four steps. Two of them are server-to-server calls from your backend, one is the widget in your
1528+ page, and the last is the ordinary OAuth2 token request you already make today.
1529+
1530+ 1. Your backend calls **`POST /api/embedded/sessions`** with your client credentials and receives
1531+ a `session_token`.
1532+ 2. Your page loads the widget script and renders `<eeid-widget>` with that token. The user picks
1533+ a method and authenticates inside the iframe.
1534+ 3. The widget tells your page it succeeded, handing over a single-use `result_token`.
1535+ 4. Your backend calls **`POST /api/embedded/sessions/{result_token}/redeem`** to obtain the
1536+ OAuth2 `code`, then exchanges it at the [identity token request](#identity-token-request) exactly as in
1537+ the redirect flow.
1538+
1539+ The browser never talks to the OAuth2 server : eeID performs the authorization request and the
1540+ login/consent steps server-side. That is what allows the flow to work when third-party cookies
1541+ are blocked.
1542+
1543+ # # Creating a session
1544+
1545+ > Create a session
1546+
1547+ ` ` ` shell
1548+ curl -X POST https://auth.eeid.ee/api/embedded/sessions \
1549+ -u "$EEID_CLIENT_ID:$EEID_CLIENT_SECRET" \
1550+ -H "Content-Type: application/json" \
1551+ -d '{
1552+ "redirect_uri": "https://www.example.com/auth/callback",
1553+ "scope": "openid",
1554+ "state": "f3b2c3e7f4cf0bed3a783ed6ece617e3",
1555+ "nonce": "9a1f4b0c2d8e6f37",
1556+ "locale": "en",
1557+ "return_url": "https://www.example.com/signin"
1558+ }'
1559+ ` ` `
1560+
1561+ > Response
1562+
1563+ ` ` ` json
1564+ {
1565+ "session_token": "gW7s8x1Qm2...",
1566+ "expires_in": 600,
1567+ "allowed_methods": null,
1568+ "locales": ["en", "et", "ru"]
1569+ }
1570+ ` ` `
1571+
1572+ URL : ` POST https://auth.eeid.ee/api/embedded/sessions`
1573+
1574+ Authentication : HTTP Basic, with your `Client ID` as the user name and your `Secret` as the
1575+ password. **This call must be made from your backend.** Never expose the secret to a browser.
1576+
1577+ Required parameters :
1578+
1579+ - ` redirect_uri` - one of the redirection URLs registered on the service. The browser is not sent
1580+ there in the embedded flow, but a real authorization request is started with it and the same
1581+ value is presented again at the token request, so it must match exactly.
1582+
1583+ Optional parameters :
1584+
1585+ - ` scope` - authentication scope, defaults to `openid`. Must correspond to the scope selected for
1586+ the service. See [Authentication scope](#authentication-scope)
1587+ - ` state` - security code against false request attacks. It is returned to you by the redeem call,
1588+ where you verify it. See [Protection](#protection)
1589+ - ` nonce` - unique parameter which helps to prevent replay attacks. Verify it in the ID token
1590+ - `locale` - user interface language : ` et` , `en` or `ru`. `ui_locales` is accepted as an alias
1591+ - ` return_url` - the page hosting the widget. Required for the authentication methods that must
1592+ leave the iframe — see [Methods that leave the frame](#methods-that-leave-the-frame). Its origin
1593+ must be on the service's allowlist
1594+ - ` theme` - a JSON object styling the widget, see [Theming](#theming)
1595+
1596+ Response fields :
1597+
1598+ - ` session_token` - the opaque token the widget is mounted with. It is not a credential of yours
1599+ and is safe to render into the page : it works only from an allowlisted origin
1600+ - ` expires_in` - seconds the session remains valid (600 by default). Create a session when the
1601+ user is about to sign in, not on every page render
1602+ - ` allowed_methods` - the authentication methods the widget will offer, or `null` for all methods
1603+ configured on the service
1604+ - ` locales` - the languages the widget supports
1605+
1606+ # # Embedding the widget
1607+
1608+ > Load the SDK and mount the widget
1609+
1610+ ` ` ` html
1611+ <script src="https://auth.eeid.ee/widget/eeid-widget.js"></script>
1612+
1613+ <eeid-widget
1614+ base-url="https://auth.eeid.ee"
1615+ session-token="gW7s8x1Qm2..."
1616+ height="420px"></eeid-widget>
1617+ ` ` `
1618+
1619+ > React to the outcome
1620+
1621+ ` ` ` javascript
1622+ const widget = document.querySelector("eeid-widget")
1623+
1624+ widget.addEventListener("eeid:success", async (event) => {
1625+ widget.remove() // required, see the note below
1626+
1627+ await fetch("/signin", {
1628+ method: "POST",
1629+ headers: { "Content-Type": "application/json" },
1630+ body: JSON.stringify({ result_token: event.detail.resultToken })
1631+ })
1632+
1633+ location.reload()
1634+ })
1635+
1636+ widget.addEventListener("eeid:error", (e) => showError(e.detail.error))
1637+ widget.addEventListener("eeid:cancel", () => showChooserAgain())
1638+ ` ` `
1639+
1640+ The SDK is served by eeID itself — there is nothing to install and no build step. It defines one
1641+ custom element that frames the sign-in form, delegates the browser permissions the framed methods
1642+ need, tracks the iframe height, and re-emits the widget's messages as ordinary DOM events.
1643+
1644+ Attributes of `<eeid-widget>` :
1645+
1646+ - ` base-url` (required) - `https://auth.eeid.ee`, or `https://test-auth.eeid.ee` for a service in
1647+ the `Test` environment
1648+ - ` session-token` (required) - the token from the create call
1649+ - ` height` - initial height, for example `420px`. The widget resizes itself afterwards
1650+ - ` theme` - a JSON theme, applied without creating a new session
1651+ - ` title` - accessible title of the iframe
1652+
1653+ Method :
1654+
1655+ - ` setTheme(partial)` - merges and applies a theme at runtime, for example when your page switches
1656+ to dark mode
1657+
1658+ Events, dispatched on the element with their data in `detail` :
1659+
1660+ - ` eeid:ready` - the form is loaded
1661+ - ` eeid:resize` - the content height changed; the SDK has already applied it
1662+ - ` eeid:success` - authentication finished. `detail.resultToken` is the single-use token to redeem,
1663+ ` detail.state` is the `state` you supplied
1664+ - ` eeid:error` - authentication failed. `detail.error` and `detail.errorDescription` carry the
1665+ OAuth2 error
1666+ - ` eeid:cancel` - the user cancelled
1667+ - ` eeid:expand` / `eeid:collapse` - a method asked for the whole viewport (identity verification
1668+ does this). The SDK handles it; listen only if your own layout needs to react
1669+
1670+ <aside class="warning">
1671+ On <code>eeid:success</code> you must remove or hide the iframe yourself. The widget hands over the
1672+ result token and then deliberately stops — it never navigates your page, because that decision is
1673+ yours. If you leave it mounted it keeps showing "Signing you in…", and a completed sign-in looks
1674+ like it hung.
1675+ </aside>
1676+
1677+ # # Redeeming the result
1678+
1679+ > Redeem the result token, then exchange the code
1680+
1681+ ` ` ` shell
1682+ curl -X POST https://auth.eeid.ee/api/embedded/sessions/$RESULT_TOKEN/redeem \
1683+ -u "$EEID_CLIENT_ID:$EEID_CLIENT_SECRET"
1684+ ` ` `
1685+
1686+ > Response
1687+
1688+ ` ` ` json
1689+ {
1690+ "code": "71ed5797c3d957817d31",
1691+ "state": "f3b2c3e7f4cf0bed3a783ed6ece617e3"
1692+ }
1693+ ` ` `
1694+
1695+ URL : ` POST https://auth.eeid.ee/api/embedded/sessions/{result_token}/redeem`
1696+
1697+ Authenticated with the same client credentials as the create call, and made from your backend.
1698+
1699+ The result token is **single-use and bound to your client** : the first valid call returns the code
1700+ and destroys the session. A replay, an expired token, or a token belonging to another client is
1701+ answered with `410 Gone` and nothing else — an attacker who intercepts a result token cannot learn
1702+ whether it ever existed.
1703+
1704+ Verify that the returned `state` matches the one you sent, then exchange the `code` with an
1705+ [identity token request](#identity-token-request), using the same `redirect_uri` you used when creating the session.
1706+ From that point the flow is identical to the redirect flow, including verification of the ID
1707+ token and its `nonce`.
1708+
1709+ # # Methods that leave the frame
1710+
1711+ Smart-ID+ and the cross-border methods (eIDAS, eParaksts, Freja, MojeID) cannot complete inside an
1712+ iframe : a mobile app link is only honoured from a top-level navigation, and a foreign identity
1713+ provider refuses to be framed. For those methods the widget takes over the whole window and then
1714+ returns to your page.
1715+
1716+ That is what `return_url` is for. Pass the URL of the page hosting the widget when you create the
1717+ session. When the user comes back, that URL carries a query parameter :
1718+
1719+ > Resuming after a method that left the frame
1720+
1721+ ` ` ` php
1722+ <?php
1723+ $sessionToken = $_GET['eeid_session_token'] ?? null;
1724+
1725+ if ($sessionToken === null) {
1726+ $sessionToken = create_session()['session_token'];
1727+ }
1728+ // mount <eeid-widget> with $sessionToken as usual
1729+ ` ` `
1730+
1731+ Mount the widget with that token and the flow finishes normally through `eeid:success`. Strip the
1732+ parameter from the address bar as you consume it — it is single-use, so a reload would otherwise
1733+ try to resume a session that is already spent.
1734+
1735+ If you omit `return_url`, those methods fall back to a plain top-level redirect to your
1736+ ` redirect_uri?code=…` , exactly like the classic redirect flow.
1737+
1738+ # # Theming
1739+
1740+ > A theme covering most needs
1741+
1742+ ` ` ` json
1743+ {
1744+ "preset": "light",
1745+ "colorScheme": "system",
1746+ "color": { "primary": "#0B5FFF", "onPrimary": "#FFFFFF" },
1747+ "typography": { "fontFamily": "\" Inter\" , system-ui, sans-serif" },
1748+ "shape": { "radiusButton": "8px" },
1749+ "branding": { "showFooter": false }
1750+ }
1751+ ` ` `
1752+
1753+ The widget is styled with a JSON theme, supplied either as the `theme` parameter when creating the
1754+ session, as the `theme` attribute on the element, or at runtime with `setTheme()` — for example
1755+ when your page switches to dark mode.
1756+
1757+ The main groups are `preset`, `colorScheme` (`light`, `dark`, `system`), `density`
1758+ (`comfortable`, `compact`), `color`, `typography`, `shape`, `spacing`, `components`, `layout`,
1759+ ` branding` and `messages`. Unknown keys are rejected rather than ignored, so a typo is reported
1760+ when the session is created instead of silently doing nothing.
1761+
1762+ Note that the first screen the user sees — the list of authentication methods — takes its colours
1763+ from `components.methodList`, not from `color.primary`, which paints buttons. Changing only the
1764+ primary colour therefore looks like nothing happened until a method is selected.
1765+
1766+ For a stylesheet of your own, `advanced.themeCssUrl` loads one extra CSS file inside the widget.
1767+ It must be served from an origin that is already on the service's allowlist.
1768+
1769+ # # Errors
1770+
1771+ | Status | Meaning |
1772+ | ------ | ------- |
1773+ | `400 Bad Request` | A parameter is invalid, for example a `return_url` whose origin is not allowlisted |
1774+ | `401 Unauthorized` | The client id or secret is wrong, or the service belongs to a different environment |
1775+ | `403 Forbidden` | Embedded mode is not enabled for this client, or no valid origin is allowlisted |
1776+ | `410 Gone` | The result token is invalid, already redeemed, or expired |
1777+ | `422 Unprocessable Entity` | The theme did not validate, or a required parameter is missing |
1778+ | `429 Too Many Requests` | Rate limited, see below. `Retry-After` gives the seconds to wait |
1779+ | `502 Bad Gateway` | eeID could not reach its authorization server. Retry later |
1780+
1781+ Errors that happen *during* authentication reach your page as an `eeid:error` event rather than as
1782+ a status code — the session was created successfully, the authentication itself failed.
1783+
1784+ # # Rate limits
1785+
1786+ The embedded API is rate limited per client and per source address. The limits are set well above
1787+ normal traffic and exist to stop credential guessing : **failed** authentications are counted far
1788+ more strictly than ordinary calls, and a successful call clears that count.
1789+
1790+ If you receive `429 Too Many Requests`, wait the number of seconds given in the `Retry-After`
1791+ header before retrying. Repeated `401`s from a misconfigured secret are the usual way to reach it,
1792+ so fix the credentials rather than retrying in a loop.
1793+
1794+ # # Test environment
1795+
1796+ A service in the `Test` environment uses `https://test-auth.eeid.ee` for all three URLs — the
1797+ session API, the widget script and the `base-url` attribute — and the matching token endpoint.
1798+ Test authentications are free. A service exists in exactly one environment at a time, so use the
1799+ host that matches the service your credentials belong to.
1800+
1801+ # # Example application
1802+
1803+ A complete integration, small enough to read in one sitting, is published at
1804+ [github.com/internetee/eeid_php_embedded_demo](https://github.com/internetee/eeid_php_embedded_demo).
1805+
1806+ It is a single PHP file plus a `<script>` tag, and it runs the whole loop : creating the session
1807+ server-side, framing the widget, redeeming the result token, exchanging it for the ID token and
1808+ displaying the claims. It also demonstrates theming, locale selection, signing out and signing in
1809+ again, and the resume path for the methods that leave the frame.
1810+
1811+ ` ` ` shell
1812+ git clone https://github.com/internetee/eeid_php_embedded_demo.git
1813+ cd eeid_php_embedded_demo
1814+ cp .env.example .env # fill in EEID_CLIENT_ID and EEID_CLIENT_SECRET
1815+ docker compose up --build
1816+ ` ` `
1817+
1818+ Then open [http://localhost:8081](http://localhost:8081). Remember to add `http://localhost:8081`
1819+ to your service's **Embedded widget allowed origins** first, or the browser will refuse to display
1820+ the widget.
1821+
14641822# eeID Identification
14651823
14661824The eeID Identification Service allows organizations to create identification requests and verify user identities based on specific criteria. It is built on a robust framework that ensures security, compliance, and ease of use.
0 commit comments