Skip to content

Device Flow Specification

Nikos Sklikas edited this page Sep 26, 2024 · 6 revisions

Abstract

Ory Hydra does not support the device grant. The device grant is required to authenticate on machines that do not support opening a browser (e.g. a VM CLI) or when it is not easy for the user to type (e.g. a TV). In this document we are going to describe the implementation of the device flow on Hydra.

Rationale

Ory Hydra does not implement the device flow. There have been attempts to implement it, but the PRs have been open for too long and breaking changes have been introduced to Hydra. After discussions between the Canonical IAM team and Ory, we have decided to fork Hydra, implement the device flow and then try to merge our changes upstream. This means that the IAM team will also be responsible for rebasing the changes and keeping the fork up-to-date.

Specification

The Hydra API will have to implement the device grant as described in RFC 8628. Changes will be required to both Hydra and its underlying oauth2/OIDC library, fosite. Fosite will have to implement all the logic that is required in the RFC, while hydra will have to implement the logic for verifying the user_code, the persistence layer and the flow for authenticating the user

Flow

A high level overview of what the flow will look like can be seen in this diagram:

hydra_device_flow drawio

  1. The app requests authorization for the user by making a call to the device authorization endpoint (/oauth2/device/auth) as described in RFC8628 section-3.1

  2. Hydra returns device code, user code, verification URL as described in RFC8628 section-3.2

  3. User is presented the verification URL (oauth2/device/verify) and user code

  4. User goes to the verification URL using a browser, this is a URL served by Hydra

  5. User is redirected to the login UI device page with a device_challenge in the URL query params

  6. User enters the user code

  7. The login UI makes a PUT request to Hydra with the device_challenge in the query parameters and the user_code in the body. A device_verifier is returned.

  8. Hydra responds with a URL to which the user is redirected to, e.g. oauth2/device/verify?client_id=<client_id>&device_verifier=<device_verifier>

  9. A standard Hydra login flow is initialized, by redirecting the user to the login UI with a login_challenge

  10. User logs in

  11. The login UI accepts the login challenge

  12. User is redirected to /oauth2/device/auth, with the login_verifier and the client_id in the query params

  13. User is redirected to the consent page with a consent_challenge

  14. User gives consent (in our case no action is needed in this step)

  15. The login UI accepts the consent_challenge and gets a consent_verifier

  16. After the user accepts the consent, they are redirected to the device endpoint (/oauth2/device/verify), but this time they have the consent_verifier and the client_id in the query params and they are redirected to the post device auth page.

In the meantime the device application keeps polling the token endpoints (/oauth2/token) as described in RFC8628 section-3.4 and Hydra responds as described in RFC8628 section-3.5.

API Changes

In order to implement this flow the following changes to the Hydra API will be needed:

/oauth2/device/auth

We will need to implement the device authorization endpoint, this will be implemented as described in RFC8628. Most of the work for this will need to be done in fosite.

/oauth2/device/verify

This is the verification URL that will be presented to the user. The user will be sent here to initialize the flow. This is designed in a similar fashion to how the authentication endpoint (/oauth2/auth) works in hydra.

It will handle only GET requests.

It will accept these query parameters:

  • device_verifier
  • login_verifier
  • consent_verifier
  • client_id
  • user_code

This endpoint is called multiple times throughout the flow and depending on the parameters provided, it will provides different functionality.

Before device verification

The user will open their browser on this endpoint. This is the verification URL that will be returned to the device that wishes to authenticate the user.

In this case there will be no device_verifier, login_verifier or consent_verifier are provided in the query params. The request may have a user_code. a device flow will be initialized and the user will be redirected to the device verification UI. The redirect URL will include a device_challenge query parameter and a user_code query parameter (if one was provided). The device_challenge will be the encoded device flow. A CSRF cookie will be set for the user. The CSRF cookie will always have the same name for all the clients because at this point we don’t know the client_id of the app that initiated the flow.

After accepting the user_code

After the user has accepted the user code, by calling the /oauth2/auth/requests/device/accept endpoint, they will be redirected back to this endpoint with a device_verifier in the query params. No login_verifier or consent_verifier must be present in the query params. The device_verifier will be validated, by comparing it against the device CSRF cookie, and marked as used.

If there is an existing login session in the user’s cookies then the flow will be over and the user will be redirected to the post device authorization URL.

Otherwise a login flow will be initialized and the user will be redirected to the login UI. The login flow will contain a reference to the device flow contained in the device_verifier. The redirect URL will include a login_challenge query parameter. The login_challenge will be the encoded flow. A CSRF token will be set for the user. The CSRF cookie name will be suffixed with the client_id.

After successfully logging in

After the user logs in and the login_challenge is accepted, they will be redirected back to this endpoint with a login_verifier and the client_id in the query params. No consent_verifier must be present in the query params.

The login_verifier will be validated and marked as used. The login flow will be updated and the user will be redirected to the consent UI. The redirect URL will include a consent_challenge query parameter. The consent_challenge will be the encoded flow. A CSRF token will be set for the user. The CSRF cookie name will be suffixed with the client_id.

After giving consent

After the user gives consent and the consent_challenge is accepted, they will be redirected back to this endpoint with a consent_verifier and the client_id in the query params.

The consent_verifier will be validated and marked as used. The login flow will be updated and persisted to the database so that it can’t be used again. The user will be redirected to the post_device UI with the consent_verifier in the query parameters.

Notes

  • We need to add a device consent step to the flow. This should be either after the user has accepted the user_code or after they have given consent. The second option seems more correct, but the first option is less disruptive to the existing flow.

/oauth2/token

The token endpoint must be updated to support the urn:ietf:params:oauth:grant-type:device_code grant_type as described in RFC8628 section-3.4.

/oauth2/auth/requests/device/accept

The consent API must implement this endpoint. It will handle only PUT requests. Similar to the other consent endpoints it will expect the following query parameters:

  • device_challenge, the device_challenge

The request body must be a json with the following keys:

  • user_code, the user_code

If the user_code is valid then it is marked as used, the device flow is updated with the user_code and a device_verifier is produced. The response is json encoded and it includes the following fields:

  • redirect_to: the URL to which the user must be redirected to, to continue their login flow. This URL will be /oauth2/device/verify and it will have the following fields: device_verifier and client_id.

/oauth2/auth/requests/device/reject

TBD

Database Changes

Device code table

A table will need to be added for the device_code called hydra_oauth2_device_code. The proposed table schema is the following (for postgres):

CREATE TABLE IF NOT EXISTS hydra_oauth2_device_code (
    signature          VARCHAR(255) NOT NULL PRIMARY KEY,
    request_id         VARCHAR(40)  NOT NULL,
    requested_at       TIMESTAMP    NOT NULL DEFAULT NOW(),
    client_id          VARCHAR(255) NOT NULL,
    scope              TEXT         NOT NULL,
    granted_scope      TEXT         NOT NULL,
    form_data          TEXT         NOT NULL,
    session_data       TEXT         NOT NULL,
    subject            VARCHAR(255) NOT NULL DEFAULT '',
    active             BOOL         NOT NULL DEFAULT true,
    requested_audience TEXT         NULL DEFAULT '',
    granted_audience   TEXT         NULL DEFAULT '',
    challenge_id       VARCHAR(40)  NULL,
    expires_at         TIMESTAMP    NULL,
    nid                UUID         NULL,

    FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE,
    FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE
);

CREATE INDEX hydra_oauth2_device_code_request_id_idx ON hydra_oauth2_device_code (request_id, nid);
CREATE INDEX hydra_oauth2_device_code_client_id_idx ON hydra_oauth2_device_code (client_id, nid);
CREATE INDEX hydra_oauth2_device_code_challenge_id_idx ON hydra_oauth2_device_code (challenge_id);

The device codes will not be stored in the database, instead we will store an HMAC signature of the actual code. This is how Hydra stores all tokens in the database.

This table follows the structure of all hydra token tables in order to reuse the existing persistence layer logic.

User code table

A table will need to be added for the user_code called hydra_oauth2_user_code. The proposed table schema is the following (for postgres):


CREATE TABLE IF NOT EXISTS hydra_oauth2_user_code (
    signature          VARCHAR(255) NOT NULL PRIMARY KEY,
    request_id         VARCHAR(40) NOT NULL,
    requested_at       TIMESTAMP    NOT NULL DEFAULT NOW(),
    client_id          VARCHAR(255) NOT NULL,
    scope              TEXT         NOT NULL,
    granted_scope      TEXT         NOT NULL,
    form_data          TEXT         NOT NULL,
    session_data       TEXT         NOT NULL,
    subject            VARCHAR(255) NOT NULL DEFAULT '',
    active             BOOL         NOT NULL DEFAULT true,
    requested_audience TEXT         NULL DEFAULT '',
    granted_audience   TEXT         NULL DEFAULT '',
    challenge_id       VARCHAR(40)  NULL,
    expires_at         TIMESTAMP    NULL,
    nid                UUID         NULL,

    FOREIGN KEY (client_id, nid) REFERENCES hydra_client(id, nid) ON DELETE CASCADE,
    FOREIGN KEY (nid) REFERENCES networks(id) ON UPDATE RESTRICT ON DELETE CASCADE
);

CREATE INDEX hydra_oauth2_user_code_request_id_idx ON hydra_oauth2_user_code (request_id, nid);
CREATE INDEX hydra_oauth2_user_code_client_id_idx ON hydra_oauth2_user_code (client_id, nid);
CREATE INDEX hydra_oauth2_user_code_challenge_id_idx ON hydra_oauth2_user_code (challenge_id);

The user codes will not be stored in the database, instead we will store an HMAC signature of the actual code. This is how Hydra stores all tokens in the database.

This table follows the structure of all hydra token tables in order to reuse the existing persistence layer logic.

Note: We could use a single table for the user and device codes, but that would prevent us from reusing the existing logic.

Flow table

Hydra stores flows in the database after they have been used. This means that at first the UI gets a *_challenge that is an encoded flow. When the UI accepts a consent_challenge, the flow is persisted in the database and a verifier is returned. Ideally we would like to enhance the current flow table to include the device flow. The current flow table can be seen here:

column type length default
login_challenge (pk) character varying 40
login_verifier character varying 40
login_csrf character varying 40
subject character varying 255
request_url text
login_skip boolean
client_id character varying 255
requested_at timestamp without time zone now()
login_initialized_at timestamp without time zone
oidc_context jsonb '{}'::jsonb
login_session_id character varying 40
state integer
login_remember boolean false
login_remember_for integer
login_error text
acr text ''::text
login_authenticated_at timestamp without time zone
login_was_used boolean false
forced_subject_identifier character varying 255 ''::character varying
context jsonb '{}'::jsonb
consent_challenge_id character varying 40
consent_skip boolean false
consent_verifier character varying 40
consent_csrf character varying 40
consent_remember boolean false
consent_remember_for integer
consent_handled_at timestamp without time zone
consent_error text
session_access_token jsonb '{}'::jsonb
session_id_token jsonb '{}'::jsonb
consent_was_used boolean false
nid uuid
requested_scope jsonb
requested_at_audience jsonb '[]'::jsonb
amr jsonb '[]'::jsonb
granted_scope jsonb
granted_at_audience jsonb '[]'::jsonb
login_extend_session_lifespan boolean false
identity_provider_session_id character varying 40

And it includes the following constraint/check:

    "hydra_oauth2_flow_check" CHECK (state = 128 OR state = 129 OR state = 1 OR state = 2 AND login_remember IS NOT NULL AND login_remember_for IS NOT NULL AND login_error IS NOT NULL AND acr IS NOT NULL AND login_was_used IS NOT NULL AND context IS NOT NULL AND amr IS NOT NULL OR state = 3 AND login_remember IS NOT NULL AND login_remember_for IS NOT NULL AND login_error IS NOT NULL AND acr IS NOT NULL AND login_was_used IS NOT NULL AND context IS NOT NULL AND amr IS NOT NULL OR state = 4 AND login_remember IS NOT NULL AND login_remember_for IS NOT NULL AND login_error IS NOT NULL AND acr IS NOT NULL AND login_was_used IS NOT NULL AND context IS NOT NULL AND amr IS NOT NULL AND consent_challenge_id IS NOT NULL AND consent_verifier IS NOT NULL AND consent_skip IS NOT NULL AND consent_csrf IS NOT NULL OR state = 5 AND login_remember IS NOT NULL AND login_remember_for IS NOT NULL AND login_error IS NOT NULL AND acr IS NOT NULL AND login_was_used IS NOT NULL AND context IS NOT NULL AND amr IS NOT NULL AND consent_challenge_id IS NOT NULL AND consent_verifier IS NOT NULL AND consent_skip IS NOT NULL AND consent_csrf IS NOT NULL OR state = 6 AND login_remember IS NOT NULL AND login_remember_for IS NOT NULL AND login_error IS NOT NULL AND acr IS NOT NULL AND login_was_used IS NOT NULL AND context IS NOT NULL AND amr IS NOT NULL AND consent_challenge_id IS NOT NULL AND consent_verifier IS NOT NULL AND consent_skip IS NOT NULL AND consent_csrf IS NOT NULL AND granted_scope IS NOT NULL AND consent_remember IS NOT NULL AND consent_remember_for IS NOT NULL AND consent_error IS NOT NULL AND session_access_token IS NOT NULL AND session_id_token IS NOT NULL AND consent_was_used IS NOT NULL)

This check constraint validates the state of the flow. The different states of the flow can be seen here. The problem that this creates is that in order to support the device flow, we would have to add the following states to the flow:

  • DEVICE_INITIALIZED
  • DEVICE_UNUSED
  • DEVICE_USED
  • DEVICE_ERROR

This will cause the database to re-validate the whole table, which in large deployments may cause downtime. We could use directives like the postgres NOT VALID to make the migration a non-blocking operation, but it would require further actions by the db admins (like validating the constraint asynchronously). Since most of the states are never persisted in the database, we have decided not to alter this constraint and introduce the new flow states only on the service layer.

We are going to add the following columns:

column type length
device_challenge_id character varying 255
device_code_request_id character varying 255
device_verifier character varying 40
device_csrf character varying 40
device_user_code_accepted_at timestamp without time zone
device_was_used boolean
device_handled_at timestamp without time zone
device_error text

And the following index:

CREATE INDEX hydra_oauth2_flow_device_challenge_idx ON hydra_oauth2_flow (device_challenge_id);

Configuration

The following configuration will be added to Hydra.

serve.cookies.names.device_csrf

The name of the device flow CSRF cookie.

webfinger.oidc_discovery.device_authorization_url

A URL used to override the value of the device_authorization_endpoint that will be shown in the provider’s metadata.

ttl.device_user_code

The lifespan of the device_code and user_code. The RFC states that these 2 tokens must have the same lifespan.

urls.device_verification

The URL of the UI where the user will be redirected in order to provide the user_code.

urls.post_device_done

The URL of the UI where the user will be redirected after the login flow initiated by a device is done.

oauth2.device_authorization.token_polling_interval

The allowed polling interval of the token endpoint.

New UI pages

The UI will need to implement the following new pages.

Device Verification

The user will be redirected to this page/endpoint with the following query params:

  • device_challenge: This parameter will always be there.
  • user_code: This parameter may not be present.

This page should instruct the user to enter the user_code that was shown to them by the device, if the user_code was provided in the query parameters then the user_code UI field should be automatically filled with the provided value. When the user has entered the user code, the user_code should be accepted by calling the /oauth2/auth/requests/device/accept endpoint.

Post Device Done

The user will be redirected to this page/endpoint after they have completed the device/login/consent flow. The following query parameters will be present in the URL:

  • client_id: the client_id of the client that initiated the flow

This page should tell the user that they have successfully logged in with the device that initiated the flow.

Implementation Details

In this section we will describe any implementation details.

Discovery Metadata

The Hydra discovery metadata needs to be updated to include the device_authorization_endpoint key, see RFC8628 section-4.

Polling rate limiting

In the first version we are going to implement polling rate limiting using a memory cache. This is not 100% correct and may result in incorrect behavior when running on a distributed setup.

User device consent

The response of the GetConsentRequest API, will be updated to include the DeviceChallenge. This will be enough to signify to the consent UI that this is a device flow and that it need to behave accordingly.

Appendix

Flow

The current login flow implementation is designed to minimize the number of database queries. In the current login the flow goes through these stages:

  1. A flow is initialized when the user visits the authorization endpoint and returned to the user as a login_challenge
  2. The login_challenge is exchanged with a login_verifier
  3. A consent_challenge is created from the login_verifier when the user visits the authorization endpoint again (with the login_verifier in the query params)
  4. Finally the consent_challenge is exchanged with a consent_verifier
  5. The flow represented by the consent_verifier is invalidated and persisted to the database once the user visits the authorization endpoint (with the consent_verifier in the query params).

The flow is encrypted using the ChaCha20-Poly1305 encryption algorithm and sent around as a query parameter. The flow contains a CSRF field which is stored in the user’s cookie for csrf protection. All these challenges and verifiers represent the same flow, but with different states.

The flow is persisted to the database only after the consent challenge is used. This means that the steps 1-4 can happen multiple times, each time returning a different challenge/verifier. The first consent_verifier to be used on the authorization endpoint is persisted to the database so if another consent_verifier with the same ID is used on the authorization endpoint it is rejected.