-
Notifications
You must be signed in to change notification settings - Fork 1
Device Flow Specification
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.
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.
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
A high level overview of what the flow will look like can be seen in this diagram:
-
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
-
Hydra returns device code, user code, verification URL as described in RFC8628 section-3.2
-
User is presented the verification URL (oauth2/device/verify) and user code
-
User goes to the verification URL using a browser, this is a URL served by Hydra
-
User is redirected to the login UI device page with a
device_challenge
in the URL query params -
User enters the user code
-
The login UI makes a PUT request to Hydra with the
device_challenge
in the query parameters and theuser_code
in the body. Adevice_verifier
is returned. -
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>
-
A standard Hydra login flow is initialized, by redirecting the user to the login UI with a
login_challenge
-
User logs in
-
The login UI accepts the login challenge
-
User is redirected to /oauth2/device/auth, with the
login_verifier
and theclient_id
in the query params -
User is redirected to the consent page with a
consent_challenge
-
User gives consent (in our case no action is needed in this step)
-
The login UI accepts the
consent_challenge
and gets aconsent_verifier
-
After the user accepts the consent, they are redirected to the device endpoint (
/oauth2/device/verify
), but this time they have theconsent_verifier
and theclient_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.
In order to implement this flow the following changes to the Hydra API will be needed:
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.
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.
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 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 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 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.
- 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.
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.
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
andclient_id
.
TBD
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.
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.
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);
The following configuration will be added to Hydra.
The name of the device flow CSRF cookie.
A URL used to override the value of the device_authorization_endpoint that will be shown in the provider’s metadata.
The lifespan of the device_code and user_code. The RFC states that these 2 tokens must have the same lifespan.
The URL of the UI where the user will be redirected in order to provide the user_code.
The URL of the UI where the user will be redirected after the login flow initiated by a device is done.
The allowed polling interval of the token endpoint.
The UI will need to implement the following new pages.
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.
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.
In this section we will describe any implementation details.
The Hydra discovery metadata needs to be updated to include the device_authorization_endpoint
key, see RFC8628 section-4.
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.
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.
The current login flow implementation is designed to minimize the number of database queries. In the current login the flow goes through these stages:
- A flow is initialized when the user visits the authorization endpoint and returned to the user as a
login_challenge
- The
login_challenge
is exchanged with alogin_verifier
- A
consent_challenge
is created from thelogin_verifier
when the user visits the authorization endpoint again (with thelogin_verifier
in the query params) - Finally the
consent_challenge
is exchanged with aconsent_verifier
- 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.