class

KemalIdentity::OIDC::Client

Inherits Reference < Object

Drives an Authorization Code + PKCE flow against one Provider.

Two calls, and the application carries Pending between them:

request = client.authorize(return_to: "/dashboard")
# store request.pending, then redirect to request.url

identity = client.complete(pending, state: ..., code: ...)

#complete returns an Identity or a Failed, never a raised exception for anything the browser or the provider controls — the callback is a public endpoint reachable by anybody with a URL.

Constants

DEFAULT_FLOW_TTL = 15.minutes

How long a flow may sit unfinished.

A login that has been open in a tab since yesterday is not a login in progress, and the window is how long a captured state is worth anything.

DEFAULT_TIMEOUT = 10.seconds

How long to wait on the token endpoint. Same reasoning as JWKS::DEFAULT_TIMEOUT: a provider that accepts connections and never answers must not hold a fiber indefinitely.

MAX_RESPONSE_BYTES = 256 * 1024

Largest token response accepted, before it is parsed.

Constructors

new(provider : Provider, clock : Clock, random : RandomSource, flow_ttl : Time::Span = DEFAULT_FLOW_TTL, timeout : Time::Span = DEFAULT_TIMEOUT, exchanger : Proc(URI, String, HTTP::Headers, Time::Span, String) | Nil = nil)
Source

Class methods

safe_return_to(value : String | Nil) : String | Nil

Whether value is somewhere this application may send a browser after login.

Only a same-site absolute path. The cases that matter and are easy to miss:

  • //evil.example.com — a browser reads a leading // as protocol-relative, so this is an absolute URL wearing a path's clothes. It is the classic open redirect.
  • /\evil.example.com — some browsers normalise the backslash to a slash, producing the same thing.
  • https://evil.example.com — an absolute URL, plainly.
  • A newline or a control character — header splitting, if the value ever reaches a Location unescaped.
Source

Instance methods

authorize(return_to : String | Nil = nil, prompt : String | Nil = nil, context : Hash(String, String) | Nil = nil) : AuthorizationRequest

Starts a flow: where to send the browser, and what to remember.

return_to is validated here, on the way in, rather than on the callback. That is deliberate: by the callback the value has made a round trip through the provider and back through the browser, and anything checked only then is checked on attacker-influenced input. Checked now and carried in signed state, it cannot be substituted.

Only a same-site path is accepted — /dashboard, not https://elsewhere.example.com and not //elsewhere.example.com, which a browser reads as a protocol-relative absolute URL and is the open redirect people miss. Anything else is dropped rather than raising: a hostile ?return_to= in a link is a thing to ignore, not a 500. context is the application's own state, carried in the signed pending flow and handed back on the callback — what kind of flow this is, which account started it, which session. Pending#context says what may go in it, and what may not: it is signed rather than encrypted, so it is not a place for secrets, and comparing it on the way back is the application's job.

Source
complete(pending : Pending, state : String | Nil, code : String | Nil, error : String | Nil = nil) : Federation::Identity | Failed

Finishes a flow from the callback's parameters.

The order below is the order it has to be in. state is compared before anything is sent anywhere, because a mismatched state means this callback did not come from a flow this application started, and exchanging its code would be doing an attacker's work.

Source
flow_ttl
Source
provider
Source