///|
/// device_flow.mbt — generic RFC 8628 device authorization grant polling.
///
/// Providers supply their endpoints + client_id; this module handles the
/// shared polling logic (interval, slow_down backoff, timeout, cancellation).
///|
/// Configuration for a device authorization flow.
pub(all) struct DeviceFlowConfig {
client_id : String
device_auth_endpoint : String // POST to get device_code + user_code
token_endpoint : String // POST to poll for access_token
scope : String // retained for provider metadata; Kimi's wire omits it
/// Provider-declared default headers merged into every device-flow POST.
/// Providers that need product identity headers (User-Agent, X-Msh-*, etc.)
/// set them here; the generic RFC 8628 logic stays provider-agnostic.
/// `Content-Type` and `Accept` set by the flow always override these.
default_headers : Map[String, String]
}
///|
/// Build a `DeviceFlowConfig` with a sane empty `default_headers`. Providers
/// that carry identity headers call `.with_default_headers` after.
pub fn DeviceFlowConfig::DeviceFlowConfig(
client_id~ : String,
device_auth_endpoint~ : String,
token_endpoint~ : String,
scope? : String = "",
) -> DeviceFlowConfig {
{
client_id,
device_auth_endpoint,
token_endpoint,
scope,
default_headers: Map::from_array([]),
}
}
///|
/// Return a copy of `self` with `headers` merged into `default_headers`.
/// Callers' own request-scoped `Content-Type` / `Accept` still win.
pub fn DeviceFlowConfig::with_default_headers(
self : DeviceFlowConfig,
headers : Map[String, String],
) -> DeviceFlowConfig {
let merged = Map::from_array([])
for k, v in self.default_headers {
merged[k] = v
}
for k, v in headers {
merged[k] = v
}
{ ..self, default_headers: merged, }
}
///|
/// Response from the device authorization endpoint (RFC 8628 §3.2).
pub(all) struct DeviceAuthResponse {
device_code : String
user_code : String
verification_uri : String
verification_uri_complete : String? // URI with user_code embedded
expires_in : Int // seconds until device_code expires
interval : Int // minimum poll interval in seconds
}
///|
/// Parse the device authorization response. Kimi Code follows pi's strict
/// contract: all four device/verification strings are required and non-empty.
fn parse_device_auth_response(json : Json) -> DeviceAuthResponse? {
match json {
Json::Object(map) => {
let device_code = match map.get("device_code") {
Some(Json::String(s)) => if s.length() > 0 { s } else { return None }
_ => return None
}
let user_code = match map.get("user_code") {
Some(Json::String(s)) => if s.length() > 0 { s } else { return None }
_ => return None
}
let verification_uri = match map.get("verification_uri") {
Some(Json::String(s)) =>
if s.length() > 0 && is_trusted_http_uri(s) {
s
} else {
return None
}
_ => return None
}
let verification_uri_complete = match
map.get("verification_uri_complete") {
Some(Json::String(s)) =>
if s.length() > 0 && is_trusted_http_uri(s) {
Some(s)
} else {
return None
}
_ => return None
}
let expires_in = match map.get("expires_in") {
Some(Json::Number(n, ..)) =>
match parse_positive_integer(n) {
Some(value) => value
None => return None
}
_ => 900 // RFC 8628 default is 15 minutes in the pi helper.
}
let interval = match map.get("interval") {
Some(Json::Number(n, ..)) =>
match parse_positive_integer(n) {
Some(value) => value
None => return None
}
_ => 5 // RFC 8628 default is 5 seconds.
}
Some({
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in,
interval,
})
}
_ => None
}
}
///|
/// Parse only finite, strictly positive integer JSON numbers. Truncating a
/// fractional expiry/interval would silently alter provider timing semantics.
fn parse_positive_integer(value : Double) -> Int? {
if value.is_nan() || value.is_inf() || value <= 0.0 {
return None
}
let integer = value.to_int()
if integer <= 0 || value != integer.to_double() {
None
} else {
Some(integer)
}
}
///|
/// OAuth device responses carry a URL that the host may open. Accept only
/// absolute HTTP(S) URLs and reject whitespace/control characters; in
/// particular, never hand a javascript:/file: or empty-host URI to a host UI.
fn is_trusted_http_uri(value : String) -> Bool {
let host = if value.has_prefix("https://") {
value.exact_view(start=8)
} else if value.has_prefix("http://") {
value.exact_view(start=7)
} else {
return false
}
if host.length() == 0 {
return false
}
for ch in host {
if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch.to_int() < 32 {
return false
}
}
true
}
///|
/// Merge a config's `default_headers` with the flow's own headers. The flow's
/// `Content-Type` / `Accept` always override any same-named default, so the
/// RFC 8628 form/JSON wire contract stays authoritative.
fn merge_flow_headers(
defaults : Map[String, String],
flow_headers : Map[String, String],
) -> Map[String, String] {
let out : Map[String, String] = Map::from_array([])
for k, v in defaults {
out[k] = v
}
for k, v in flow_headers {
out[k] = v
}
out
}
///|
/// Request a device code from the provider. POST form-encoded body;
/// returns the device auth response or raises OAuthError.
async fn request_device_code(
config : DeviceFlowConfig,
transport : &OAuthHttpTransport,
) -> DeviceAuthResponse raise OAuthError {
let flow_headers : Map[String, String] = Map([
("Content-Type", "application/x-www-form-urlencoded"),
("Accept", "application/json"),
])
let headers = merge_flow_headers(config.default_headers, flow_headers)
// Kimi Code follows pi's wire contract: scope is not sent on this endpoint.
let body = "client_id=" + url_encode(config.client_id)
let response = transport.post(OAuthHttpRequest::{
url: config.device_auth_endpoint,
body,
headers,
})
if response.status < 200 || response.status >= 300 {
raise OAuthError::HttpError(
"device auth HTTP status=" +
response.status.to_string() +
", body_chars=" +
response.body.length().to_string(),
)
}
let resp_json = @json.parse(response.body) catch {
err =>
raise OAuthError::ParseError("device auth response: " + err.to_string())
}
match parse_device_auth_response(resp_json) {
Some(dar) => dar
None =>
raise OAuthError::ParseError(
"missing device_code/user_code/verification_uri_complete or invalid http(s) URI in verification fields",
)
}
}
///|
/// Token polling outcome. RFC 8628 pending/slow_down are control flow rather
/// than failures; terminal statuses remain typed OAuth errors.
priv enum PollTokenResult {
Pending
SlowDown(Int) // server interval, or 0 when omitted
Complete(Credential)
Failed(OAuthError)
}
///|
/// Poll the token endpoint once. The response body is parsed even for HTTP
/// 400 because RFC 8628 uses that status for authorization_pending/slow_down.
async fn poll_token(
config : DeviceFlowConfig,
device_code : String,
transport : &OAuthHttpTransport,
) -> PollTokenResult raise OAuthError {
let flow_headers : Map[String, String] = Map([
("Content-Type", "application/x-www-form-urlencoded"),
("Accept", "application/json"),
])
let headers = merge_flow_headers(config.default_headers, flow_headers)
let body = "client_id=" +
url_encode(config.client_id) +
"&device_code=" +
url_encode(device_code) +
"&grant_type=" +
url_encode("urn:ietf:params:oauth:grant-type:device_code")
let response = transport.post(OAuthHttpRequest::{
url: config.token_endpoint,
body,
headers,
})
// Server failures are terminal for this poll attempt. Do not parse an
// arbitrary HTML/proxy body as OAuth JSON; preserve only safe diagnostics.
if response.status >= 500 {
return Failed(
HttpError(
"token HTTP status=" +
response.status.to_string() +
", body_chars=" +
response.body.length().to_string(),
),
)
}
let resp_json = @json.parse(response.body) catch {
err => raise OAuthError::ParseError("token response: " + err.to_string())
}
match resp_json {
Json::Object(map) =>
// Check for error response (RFC 8628 §3.5).
match map.get("error") {
Some(Json::String(err_code)) =>
match err_code {
"authorization_pending" => Pending
"slow_down" => {
let interval = match map.get("interval") {
Some(Json::Number(n, ..)) =>
match parse_positive_integer(n) {
Some(value) => value
None =>
return Failed(
ParseError(
"invalid interval in slow_down token response",
),
)
}
_ => 0
}
SlowDown(interval)
}
"access_denied" => Failed(AccessDenied)
"expired_token" => Failed(ExpiredToken)
"invalid_grant" => Failed(InvalidGrant)
other =>
Failed(
HttpError(
"token error=" +
other +
", status=" +
response.status.to_string(),
),
)
}
_ => {
if response.status < 200 || response.status >= 300 {
return Failed(
HttpError("token HTTP status=" + response.status.to_string()),
)
}
// Success response. pi requires all token fields that are needed to
// persist and refresh a subscription credential.
let access_token = match map.get("access_token") {
Some(Json::String(s)) =>
if s.length() > 0 {
s
} else {
return Failed(
ParseError("missing access_token in success response"),
)
}
_ =>
return Failed(
ParseError("missing access_token in success response"),
)
}
let refresh_token = match map.get("refresh_token") {
Some(Json::String(s)) =>
if s.length() > 0 {
s
} else {
return Failed(
ParseError("missing refresh_token in success response"),
)
}
_ =>
return Failed(
ParseError("missing refresh_token in success response"),
)
}
let expires_in = match map.get("expires_in") {
Some(Json::Number(n, ..)) =>
match parse_positive_integer(n) {
Some(value) => value
None =>
return Failed(
ParseError("invalid expires_in in success response"),
)
}
_ =>
return Failed(
ParseError("missing expires_in in success response"),
)
}
let token_type = match map.get("token_type") {
Some(Json::String(s)) => if s.length() > 0 { s } else { "Bearer" }
_ => "Bearer"
}
let now_secs = (@async.now() / 1000L).to_int()
Complete(
Credential::Credential(
access_token~,
refresh_token~,
expires_at=now_secs + expires_in,
token_type~,
),
)
}
}
_ => Failed(ParseError("token response is not an object"))
}
}
///|
/// Run the complete device flow with the default async HTTP transport.
///
/// Native/js only: this convenience constructs a `DefaultOAuthHttpTransport`,
/// whose `OAuthHttpTransport` impl is compiled only on those backends. On
/// wasm/wasm-gc, use `run_device_flow_with_transport` with a host-supplied
/// transport.
#cfg(any(target="native", target="js"))
pub async fn run_device_flow(
config : DeviceFlowConfig,
interaction : &AuthInteraction,
) -> Credential raise OAuthError {
let default_transport = DefaultOAuthHttpTransport::DefaultOAuthHttpTransport()
let transport : &OAuthHttpTransport = default_transport
run_device_flow_with_transport(config, interaction, transport)
}
///|
#cfg(not(any(target="native", target="js")))
pub fn run_device_flow(
_config : DeviceFlowConfig,
_interaction : &AuthInteraction,
) -> Credential raise OAuthError {
raise Cancelled
}
///|
/// Run a device flow with an injected HTTP transport. This is the canonical
/// seam for deterministic tests and hosts that need explicit request tracing.
pub async fn run_device_flow_with_transport(
config : DeviceFlowConfig,
interaction : &AuthInteraction,
transport : &OAuthHttpTransport,
) -> Credential raise OAuthError {
let dar = request_device_code(config, transport)
let verification_uri = match dar.verification_uri_complete {
Some(uri) => uri
None => dar.verification_uri
}
interaction.notify(AuthMessage::DeviceCode(verification_uri, dar.user_code))
let now_secs = (@async.now() / 1000L).to_int()
let deadline = now_secs + dar.expires_in
let mut interval = dar.interval
while (@async.now() / 1000L).to_int() < deadline {
if interaction.is_cancelled() {
raise Cancelled
}
// RFC 8628 interval is seconds; @async.sleep takes milliseconds. This is
// the single sleep point, so slow_down-derived intervals convert here too.
@async.sleep(interval * 1000)
let result = poll_token(config, dar.device_code, transport)
match result {
Pending => ()
SlowDown(server_interval) =>
interval = if server_interval > 0 {
server_interval
} else {
interval + 5
}
Complete(credential) => return credential
Failed(error) => raise error
}
}
raise ExpiredToken
}