Table of Contents

Class DapperBackChannelAuthenticationRequestStore

Namespace
Fdyay.IdentityServer.Dapper.Stores
Assembly
Fdyay.IdentityServer.Dapper.dll

Dapper-backed store for pending backchannel authentication requests.

public sealed class DapperBackChannelAuthenticationRequestStore : IBackChannelAuthenticationRequestStore
Inheritance
DapperBackChannelAuthenticationRequestStore
Implements
Inherited Members

Remarks

The store a multi-instance deployment needs, and CIBA needs it more than the device flow does. A device authorization is answered by the person holding the device, in a browser they opened for the purpose; a CIBA request arrives with nobody in front of it and is answered whenever that person next reaches an approval surface — on whichever instance the balancer gives them, minutes or hours later, while the client polls whichever instance it gets. With the in-memory default those are two different dictionaries, so a request approved on one instance is invisible to the poll on another and the client waits until its expiry for an answer that already exists.

Two single-record keys, and one question the device store is never asked. The client polls by auth_req_id and the approval surface works in the internal id; a surface that shows somebody what is waiting for them asks by subject, which is a query rather than a lookup, and the reason SubjectId and IsComplete are columns instead of living only inside the serialised payload. Answering FindPendingForSubjectAsync by deserialising every row in the table would work at the scale of a test and not at the scale of a deployment.

Expiry is filtered on read as well as swept on a schedule, exactly as DapperDeviceFlowStore does it: a lapsed request stops being approvable at its expiry rather than whenever cleanup next runs.

Since 0.177.0 the auth_req_id is stored hashed and the request data-protected, as Duende's DefaultBackChannelAuthenticationRequestStore stores its ciba grant (measured: the key a SHA-256 of the handle, the data through IPersistentGrantSerializer, which protects it). The id is a bearer credential for the tokens, and the data carries it, the client's notification token and the approving user's claims; until then all three were in the table in the clear. A row written before is still read — by its unhashed key, and through the protector's plaintext fallback — so nothing needs migrating, and such a row lives only for a request's lifetime.

Constructors

DapperBackChannelAuthenticationRequestStore(IDbConnectionFactory, TimeProvider)

Creates a backchannel authentication request store over the BackChannelAuthenticationRequests table.

public DapperBackChannelAuthenticationRequestStore(IDbConnectionFactory connectionFactory, TimeProvider time)

Parameters

connectionFactory IDbConnectionFactory

Opens a connection for each operation and supplies the dialect's upsert.

time TimeProvider

The clock reads compare a row's expiry against.

Remarks

Writes each request's data unprotected, as before 0.177.0; the container builds the overload taking a protector.

DapperBackChannelAuthenticationRequestStore(IDbConnectionFactory, TimeProvider, IGrantDataProtector)

Creates the store, protecting each request's data with protector.

public DapperBackChannelAuthenticationRequestStore(IDbConnectionFactory connectionFactory, TimeProvider time, IGrantDataProtector protector)

Parameters

connectionFactory IDbConnectionFactory

Opens a connection for each operation and supplies the dialect's upsert.

time TimeProvider

The clock reads compare a row's expiry against.

protector IGrantDataProtector

Protects the Data column as it does a persisted grant's, under PersistentGrants.DataProtectData, and reads a row written unprotected as it was written.

Methods

FindByAuthenticationRequestIdAsync(string, CancellationToken)

Finds the request a client is polling for.

public Task<BackChannelAuthenticationRequest?> FindByAuthenticationRequestIdAsync(string authenticationRequestId, CancellationToken cancellationToken = default)

Parameters

authenticationRequestId string
cancellationToken CancellationToken

Returns

Task<BackChannelAuthenticationRequest>

Remarks

Reads the BackChannelAuthenticationRequests row whose AuthenticationRequestId is the id's hash, or the id itself for a row written before 0.177.0. A miss returns null, and so does a row whose ExpiresAtUtc has passed, whether or not the sweep has removed it yet.

Exceptions

ArgumentException

authenticationRequestId is null or empty.

FindByInternalIdAsync(string, CancellationToken)

Finds the request the approval surface is acting on.

public Task<BackChannelAuthenticationRequest?> FindByInternalIdAsync(string internalId, CancellationToken cancellationToken = default)

Parameters

internalId string
cancellationToken CancellationToken

Returns

Task<BackChannelAuthenticationRequest>

Remarks

Reads the BackChannelAuthenticationRequests row whose InternalId matches. A miss returns null, and so does a row whose ExpiresAtUtc has passed, whether or not the sweep has removed it yet.

Exceptions

ArgumentException

internalId is null or empty.

FindPendingForSubjectAsync(string, CancellationToken)

The requests still waiting on an answer from this subject.

public Task<IReadOnlyCollection<BackChannelAuthenticationRequest>> FindPendingForSubjectAsync(string subjectId, CancellationToken cancellationToken = default)

Parameters

subjectId string
cancellationToken CancellationToken

Returns

Task<IReadOnlyCollection<BackChannelAuthenticationRequest>>

Remarks

Both conditions are the query's, not the caller's: a request the subject has already answered is not waiting on them, and one past its expiry cannot be answered at all. Leaving either to the caller would put an approval surface in the position of showing somebody a decision they have already made.

RemoveByInternalIdAsync(string, CancellationToken)

Removes a request under both keys.

public Task RemoveByInternalIdAsync(string internalId, CancellationToken cancellationToken = default)

Parameters

internalId string
cancellationToken CancellationToken

Returns

Task

Remarks

Deletes the BackChannelAuthenticationRequests row whose InternalId matches. A miss deletes nothing and is not an error.

Exceptions

ArgumentException

internalId is null or empty.

StoreAsync(string, BackChannelAuthenticationRequest, CancellationToken)

Stores a new request under both keys.

public Task StoreAsync(string authenticationRequestId, BackChannelAuthenticationRequest request, CancellationToken cancellationToken = default)

Parameters

authenticationRequestId string
request BackChannelAuthenticationRequest
cancellationToken CancellationToken

Returns

Task

Remarks

Upserts the BackChannelAuthenticationRequests row keyed by a hash of AuthenticationRequestId, with the internal id, client id, subject id, completion flag and an ExpiresAtUtc of the request's creation time plus its lifetime in seconds as columns and the request itself as JSON. An internal id already held by another row violates the unique index on InternalId and the provider's exception propagates.

Exceptions

ArgumentException

authenticationRequestId is null or empty.

ArgumentNullException

request is null.

UpdateByInternalIdAsync(string, BackChannelAuthenticationRequest, CancellationToken)

Replaces a request, by the key the approval surface holds.

public Task UpdateByInternalIdAsync(string internalId, BackChannelAuthenticationRequest request, CancellationToken cancellationToken = default)

Parameters

internalId string
request BackChannelAuthenticationRequest
cancellationToken CancellationToken

Returns

Task

Remarks

Looks up the AuthenticationRequestId of the BackChannelAuthenticationRequests row whose InternalId matches and rewrites that whole row from request. When no row has that internal id nothing is written and no error is raised. The lookup does not check expiry.

Exceptions

ArgumentException

internalId is null or empty.

ArgumentNullException

request is null.