######################################################### OAuth2 / OpenID Connect – connecting a client application ######################################################### HORES includes its own OAuth 2.0 / OpenID Connect authorization server. It runs in the daemon (``hores_daemon.py``) as the **OAuth2** interface and is available under the ``/auth`` path. This page describes what a client application developer needs to connect to HORES. ************ 1. Addresses ************ The base address (issuer) is ``https:///auth``. The actual value is set by the HORES administrator in the OAuth2 interface settings (*Public server URL (issuer)*, e.g. ``https://pms.example.com/auth``). The setting is required, the interface does not start without a valid value: an absolute URL whose path ends with ``/auth``, no trailing slash, ``https`` (``http`` only for ``localhost``). The issuer in every response (``iss``, the addresses in discovery) is always this value and does not depend on the address a client used to reach the server. The daemon listens on port ``7443`` (HTTPS) and ``7080`` (HTTP). Only HTTPS is used in production, usually behind a reverse proxy. TLS is up to the deployment: HORES itself does not enforce HTTPS. The login page cookie gets the ``Secure`` flag when the issuer starts with ``https://``. If your client library supports it, just give it the **discovery URL**. It loads all other addresses by itself:: https:///auth/.well-known/openid-configuration .. list-table:: :header-rows: 1 * - Endpoint - URL - Method * - Discovery - ``/auth/.well-known/openid-configuration`` - GET * - JWKS (keys for id_token verification) - ``/auth/.well-known/jwks.json`` - GET * - Authorization - ``/auth/authorize`` - GET * - Token - ``/auth/token`` - POST * - UserInfo - ``/auth/userinfo`` - GET, POST * - Token revocation (RFC 7009) - ``/auth/revoke`` - POST * - Token introspection (RFC 7662) - ``/auth/introspect`` - POST * - Logout from the login page - ``/auth/logout`` - GET ********************** 2. Client registration ********************** Clients cannot be registered dynamically. The HORES administrator creates the client in the desktop application, menu **OAuth2 settings → Client applications**. The administrator gives you: - **client_id**: HORES prefills a randomly generated value, the administrator may replace it with a readable one (max. 48 characters, only ``A-Z a-z 0-9 . _ ~ -``). It cannot be changed once saved. - **client_secret** (confidential clients only): shown only once, when it is generated. HORES stores only its hash, so a forgotten secret cannot be recovered, only a new one generated. Tell the administrator: .. list-table:: :header-rows: 1 :widths: 25 75 * - Item - Note * - Client type - **Confidential** (server application that can keep a secret): ``client_secret_basic`` or ``client_secret_post``. **Public** (native mobile or desktop application): ``none``, no secret. Single-page applications (SPA) in a browser are not supported yet, see :ref:`oauth2-spa`. * - Grant types - ``authorization_code``, ``refresh_token``, ``client_credentials`` (see below). HORES does not support the implicit grant. * - Redirect URIs - One or more, must match exactly. Allowed are ``https://``, ``http://`` only for ``localhost``/``127.0.0.1``/``::1``, and a custom scheme for native applications (``com.example.app:/callback``). No fragment (``#…``). * - Scopes - Which of the scopes listed below the client may request. * - Act as user - Only for ``client_credentials``, see :ref:`oauth2-client-credentials`. * - Resource server - Only for a confidential client that verifies tokens issued to other clients through ``/auth/introspect`` (see :ref:`oauth2-introspection`). .. _oauth2-spa: 2.1 Web applications (SPA) ========================== A JavaScript application running in a browser on a different domain than HORES is **not supported yet**. Neither the ``/auth/token``, ``/auth/userinfo`` and ``/auth/revoke`` endpoints nor the HORES APIs send CORS headers, so the browser blocks ``fetch()`` calls to them. Connect a web application as a **confidential client through its own backend** (the "backend for frontend" pattern): the application server exchanges the code, stores the tokens and calls the HORES API, and the browser never sees the tokens. ********* 3. Scopes ********* .. list-table:: :header-rows: 1 :widths: 20 80 * - Scope - Meaning * - ``openid`` - Sign-in via OpenID Connect, the response contains an ``id_token``. * - ``profile`` - User details: ``preferred_username``, ``name``, ``locale``. * - ``hores_api`` - Access to the HORES GraphQL API (``/api2/graphql``) on behalf of the user. * - ``web_api`` - Access to the HORES Web API (``/api/...``) on behalf of the user. A client gets at most the scopes allowed in its registration. A request for an unknown scope is rejected with ``invalid_scope``. What the user may actually do in the API is decided by their HORES user rights, not by the scope. The scope only determines which service the token can be used for. ********************** 4. Flows (grant types) ********************** 4.1 Authorization Code + PKCE (recommended) =========================================== For all applications where a person signs in. **PKCE is mandatory for every client**, including confidential ones, and **only with the** ``S256`` **method**. The ``code_challenge_method=S256`` parameter must always be sent. Without it the server rejects the request, because according to RFC 7636 it would mean ``plain``. 1. Redirect the browser to:: GET /auth/authorize?response_type=code &client_id= &redirect_uri= &scope=openid%20profile%20hores_api &state= &nonce= &code_challenge= &code_challenge_method=S256 The user signs in with their HORES username and password, possibly with a second factor (TOTP code or security key / WebAuthn), and consents to the requested scopes. The account must have API access enabled in HORES and must not be expired. 2. HORES redirects back to ``redirect_uri?code=...&state=...``. The code is valid for **5 minutes** and can be used only once. If the user denies consent, ``error=access_denied`` is returned. 3. Exchange the code for tokens:: POST /auth/token Content-Type: application/x-www-form-urlencoded Authorization: Basic base64(client_id:client_secret) # confidential client grant_type=authorization_code &code= &redirect_uri= &code_verifier= &client_id= # public client (no Authorization header) The response contains ``access_token``, ``token_type: Bearer``, ``expires_in``, ``scope``, and optionally ``refresh_token`` (if the client has the ``refresh_token`` grant enabled) and ``id_token`` (with the ``openid`` scope). 4.2 Refresh token ================= :: POST /auth/token grant_type=refresh_token&refresh_token= Client authentication is the same as for the code exchange. **Refresh tokens are rotated**: every use returns a new access + refresh token pair and invalidates the old pair. The client must therefore always store the new ``refresh_token``. - **Reusing an old refresh token** is treated as possible theft. The request fails with ``invalid_grant`` and all tokens that came from the same sign-in are invalidated as well, including the newest pair, so the user has to sign in again. The same happens when a client sends the same refresh token twice concurrently, so serialize refreshes in the client. - **Lifetime**: a refresh token expires after **60 days of inactivity**. Every rotation issues a new token with another 60 days, so a client that refreshes regularly stays signed in. Unused for 60 days, the user has to sign in again. - On refresh the server checks the **current state** again: the user must still be allowed to sign in (API access, validity, not deleted), otherwise the refresh fails with ``invalid_grant`` and the tokens of that sign-in are invalidated. The new token only gets the scopes the client's registration allows now: a scope the administrator has removed from the client since is not renewed. .. _oauth2-client-credentials: 4.3 Client Credentials (server to server) ========================================= Confidential clients only (``client_secret_basic`` / ``client_secret_post``), no refresh token is issued. :: POST /auth/token Authorization: Basic base64(client_id:client_secret) grant_type=client_credentials&scope=hores_api - If the client has **Act as user** set in HORES, the token is issued on behalf of that technical user. That user's rights apply, and ``hores_api``, ``web_api``, ``openid``/``profile`` and ``/userinfo`` all work with it. If the user has been deactivated in the meantime, the server returns ``unauthorized_client``. - Without an assigned user the token gets no user-bound scope (``openid``, ``profile``, ``hores_api``, ``web_api``), so it cannot be used for the HORES API. 4.4 Implicit (not supported) ============================ HORES does not support the implicit grant (``response_type=token``), which RFC 9700 says must no longer be used. A request with ``response_type=token`` is rejected with ``unsupported_response_type``. Use Authorization Code + PKCE. ********* 5. Tokens ********* .. list-table:: :header-rows: 1 :widths: 20 25 55 * - - Format - Validity * - Access token - opaque string - 8 hours * - Refresh token - opaque string - 60 days since last use, rotated on every use * - id_token - JWT, RS256 signature - ``iss`` = issuer, ``aud`` = client_id, ``sub`` = HORES user ID, ``nonce`` if sent * - Authorization code - opaque string - 5 minutes, single use - Verify the ``id_token`` with the key from JWKS (``kid`` in the header) and check ``iss``, ``aud``, ``exp`` and ``nonce``. - The access token is opaque and the client must not decode it. Use ``/auth/introspect`` to check its state. - ``sub`` is the stable internal HORES user ID (a number as a string). The username is in ``preferred_username``. HORES does not issue the ``email`` claim. ***************************************** 6. Calling HORES API with an access token ***************************************** The access token is sent in the header:: Authorization: Bearer .. list-table:: :header-rows: 1 * - Service - URL - Required scope * - HORES GraphQL API (:doc:`hores_api`) - ``https:///api2/graphql`` - ``hores_api`` * - HORES Web API (:doc:`web_api`) - ``https:///api/...`` - ``web_api`` The token must contain the scope of the service and be issued on behalf of a user. On every request the server checks that the user is still active and has API access enabled. The ``admin`` user cannot access the API. An invalid, expired or revoked token, or a missing scope, returns ``401`` with the header ``WWW-Authenticate: Bearer error="invalid_token"``. The ``/api2/login`` endpoint and the ``/api2/ws`` WebSocket do not accept bearer tokens, and you don't need to call them when using an access token. ************************************** 7. UserInfo, revocation, introspection ************************************** - **UserInfo**: ``GET /auth/userinfo`` with ``Authorization: Bearer ``. The token must have the ``openid`` scope. Returns ``sub``, and with the ``profile`` scope also ``preferred_username``, ``name`` and ``locale``. - **Current state of the user**: tokens and authorization codes are only valid while the user is active (not deleted, API access enabled, not expired). Once the user is deactivated, ``/auth/userinfo`` answers ``401`` (``invalid_token``), introspection returns ``{"active": false}``, and exchanging a code issued earlier or refreshing fails with ``invalid_grant``. - **Revocation**: ``POST /auth/revoke``, parameters ``token`` and optionally ``token_type_hint``. Requires client authentication (``client_secret_basic``/``post``), so a public client cannot call it. ``token_type_hint`` only speeds up the lookup (RFC 7009). What gets invalidated depends on the actual type of the token sent: - **access token**: only the access token, the refresh token remains usable, - **refresh token**: the refresh token, its access token and all tokens that came from the same sign-in. .. _oauth2-introspection: - **Introspection**: ``POST /auth/introspect``, parameters ``token`` and optionally ``token_type_hint`` (only speeds up the lookup). Confidential clients only. Returns ``active``, ``client_id``, ``scope``, ``sub``, ``username``, ``iss``, ``iat`` and ``exp``; for a refresh token they describe the refresh token itself. A client may introspect **only its own tokens**. Only a client the administrator marked as *Resource server* may verify tokens of other clients. For anyone else the server returns ``{"active": false}``, the same as for an unknown token. - **Failed attempt limit**: after 10 failed client authentications (wrong ``client_secret``) within 15 minutes from one IP address, ``/auth/token``, ``/auth/revoke`` and ``/auth/introspect`` answer ``429`` with the error ``slow_down`` for that ``client_id``, even with the correct secret. **************** 8. Common errors **************** .. list-table:: :header-rows: 1 :widths: 35 65 * - Error - Cause * - ``invalid_request`` – Missing 'code_challenge' / 'code_challenge_method' - PKCE is missing, or ``code_challenge_method=S256`` was not sent. * - ``invalid_scope`` - A scope unknown to HORES. * - ``invalid_grant`` - The code was already used or has expired, ``code_verifier`` or ``redirect_uri`` does not match, an old (already rotated) refresh token was used, or the user is no longer allowed to sign in. * - ``unsupported_response_type`` - A ``response_type`` other than ``code`` (e.g. implicit ``token``). * - ``429`` / ``slow_down`` - Too many failed client authentications, try again later. * - ``invalid_client`` - Wrong ``client_id``/``client_secret``, or a client authentication method that is not allowed. * - ``unauthorized_client`` - The client does not have the grant enabled, or the user assigned for ``client_credentials`` is not eligible. * - Insecure transport error (InsecureTransportError) - ``redirect_uri`` over ``http://`` other than localhost. * - Wrong ``iss`` / WebAuthn not working - *Public server URL (issuer)* in the OAuth2 interface does not match the address users actually open the server at.