atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
Zig
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628const std = @import("std");const clock = @import("../core/clock.zig");const auth = @import("../auth/tokens.zig");const browser_session = @import("../internal/browser_session.zig");const config = @import("../core/config.zig");const dpop = @import("../internal/dpop.zig");const document_cache = @import("../internal/document_cache.zig");const log = @import("../core/log.zig");const http_api = @import("../http/api.zig");const permission_sets = @import("oauth/permission_sets.zig");const store = @import("../storage/store.zig");const zat = @import("zat");
const http = std.http;const request_uri_prefix = "urn:ietf:params:oauth:request_uri:";const par_expires_in: i64 = 600;const access_token_expires_in: i64 = 15 * 60;const refresh_token_expires_in: i64 = 14 * 24 * 60 * 60;const previous_refresh_grace_in: i64 = 30;const client_document_ttl_seconds: i64 = 10 * 60;
var client_document_cache = document_cache.DocumentCache(64).init(std.heap.page_allocator);
const ClientDisplayInfo = struct { name: []const u8, uri: []const u8,};
const ClientMetadata = struct { value: std.json.Value,
fn redirectUriRegistered(self: ClientMetadata, redirect_uri: []const u8) bool { const redirect_uris = switch (self.value) { .object => |object| switch (object.get("redirect_uris") orelse return false) { .array => |array| array, else => return false, }, else => return false, }; const native = std.mem.eql(u8, zat.json.getString(self.value, "application_type") orelse "web", "native"); for (redirect_uris.items) |item| { if (item != .string) continue; if (std.mem.eql(u8, item.string, redirect_uri)) return true; if (native and loopbackRedirectMatchesAnyPort(item.string, redirect_uri)) return true; } return false; }
fn registeredScope(self: ClientMetadata) ?[]const u8 { return zat.json.getString(self.value, "scope"); }};
pub fn protectedResource(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var resource_buf: [512]u8 = undefined; const requested_resource = http_api.queryParam(request.url.raw, "resource", &resource_buf); const resource = if (requested_resource) |requested| if (std.mem.eql(u8, trimTrailingSlash(requested), config.publicUrl())) requested else config.publicUrl() else config.publicUrl(); const body = try std.fmt.allocPrint(arena.allocator(), \\{{"resource":{f},"authorization_servers":[{f}],"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky"],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}} , .{ std.json.fmt(resource, .{}), std.json.fmt(config.publicUrl(), .{}) }); try http_api.json(request, .ok, body);}
pub fn authorizationServer(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const issuer = config.publicUrl(); const body = try std.fmt.allocPrint(allocator, \\{{"issuer":{f},"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:email","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true,"require_pushed_authorization_requests":true,"token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["ES256","ES256K"],"dpop_signing_alg_values_supported":["ES256","ES256K"],"prompt_values_supported":["none","login","consent","select_account","create"],"protected_resources":[{f}],"jwks_uri":{f},"authorization_endpoint":{f},"token_endpoint":{f},"revocation_endpoint":{f},"introspection_endpoint":{f},"pushed_authorization_request_endpoint":{f}}} , .{ std.json.fmt(issuer, .{}), std.json.fmt(issuer, .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/jwks"), .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/authorize"), .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/token"), .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/revoke"), .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/introspect"), .{}), std.json.fmt(try joinUrl(allocator, issuer, "/oauth/par"), .{}), }); try http_api.json(request, .ok, body);}
pub fn jwks(request: *http_api.Request) !void { try http_api.json(request, .ok, "{\"keys\":[]}");}
pub fn par(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const content_type = http_api.headerValue(request, "content-type") orelse ""; const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); if (isJsonContent(content_type)) { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); return parWithParamsHandled(request, allocator, JsonParams{ .json = parsed.value }); } return parWithParamsHandled(request, allocator, Form{ .body = body });}
fn parWithParamsHandled(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { parWithParams(request, allocator, params) catch |err| switch (err) { error.MissingFormField => return, else => return err, };}
const LoopbackRedirect = struct { host: []const u8, port: ?[]const u8, rest: []const u8,};
/// `http://127.0.0.1[:port]<rest>` or `http://[::1][:port]<rest>`; `localhost` is/// not a loopback redirect (RFC 8252 §8.3) and https never is (§7.3)fn parseLoopbackRedirect(uri: []const u8) ?LoopbackRedirect { const scheme = "http://"; if (!std.mem.startsWith(u8, uri, scheme)) return null; const after = uri[scheme.len..]; const host: []const u8 = if (std.mem.startsWith(u8, after, "127.0.0.1")) "127.0.0.1" else if (std.mem.startsWith(u8, after, "[::1]")) "[::1]" else return null; var rest = after[host.len..]; var port: ?[]const u8 = null; if (rest.len > 0 and rest[0] == ':') { const end = std.mem.indexOfAny(u8, rest, "/?#") orelse rest.len; const digits = rest[1..end]; if (digits.len == 0) return null; for (digits) |c| if (!std.ascii.isDigit(c)) return null; port = digits; rest = rest[end..]; } if (rest.len > 0 and rest[0] != '/' and rest[0] != '?' and rest[0] != '#') return null; return .{ .host = host, .port = port, .rest = rest };}
/// RFC 8252 §7.3: a native client that registers a loopback redirect without a/// port matches that URI on whatever port it bound at request time. A registered/// port still has to match exactly, as in the reference provider.fn loopbackRedirectMatchesAnyPort(registered: []const u8, requested: []const u8) bool { const allowed = parseLoopbackRedirect(registered) orelse return false; if (allowed.port != null) return false; const request = parseLoopbackRedirect(requested) orelse return false; return std.mem.eql(u8, allowed.host, request.host) and std.mem.eql(u8, allowed.rest, request.rest);}
const loopback_client_origin = "http://localhost";
const LoopbackClient = struct { scope: []const u8, redirect_uris: []const []const u8,};
/// The spec's localhost client development exception: a client_id whose origin/// is exactly `http://localhost` (no port) with an empty path is virtual, and/// its metadata comes from the query string instead of a fetch.fn isLoopbackClientId(client_id: []const u8) bool { if (!std.mem.startsWith(u8, client_id, loopback_client_origin)) return false; var rest = client_id[loopback_client_origin.len..]; if (rest.len > 0 and rest[0] == '/') rest = rest[1..]; return rest.len == 0 or rest[0] == '?';}
/// Mirrors `parseOAuthLoopbackClientId` in the reference `@atproto/oauth-types`:/// only `scope` (once) and `redirect_uri` (repeatable) are allowed, every/// redirect must be a loopback IP redirect, and the scope must include `atproto`.fn parseLoopbackClientId(allocator: std.mem.Allocator, client_id: []const u8) !LoopbackClient { if (std.mem.indexOfScalar(u8, client_id, '#') != null) return error.InvalidLoopbackClientId; var scope: ?[]const u8 = null; var redirect_uris: std.ArrayList([]const u8) = .empty; if (std.mem.indexOfScalar(u8, client_id, '?')) |query_start| { var params = std.mem.splitScalar(u8, client_id[query_start + 1 ..], '&'); while (params.next()) |param| { if (param.len == 0) continue; const eq = std.mem.indexOfScalar(u8, param, '=') orelse param.len; const raw_value = if (eq < param.len) param[eq + 1 ..] else ""; const value = http_api.percentDecode(raw_value, try allocator.alloc(u8, raw_value.len)) catch return error.InvalidLoopbackClientId; if (std.mem.eql(u8, param[0..eq], "scope")) { if (scope != null or !scopeListContains(value, "atproto")) return error.InvalidLoopbackClientId; scope = value; } else if (std.mem.eql(u8, param[0..eq], "redirect_uri")) { if (parseLoopbackRedirect(value) == null) return error.InvalidLoopbackClientId; try redirect_uris.append(allocator, value); } else return error.InvalidLoopbackClientId; } } if (redirect_uris.items.len == 0) try redirect_uris.appendSlice(allocator, &.{ "http://127.0.0.1/", "http://[::1]/" }); return .{ .scope = scope orelse "atproto", .redirect_uris = redirect_uris.items };}
fn scopeListContains(scope_text: []const u8, wanted: []const u8) bool { var scopes = std.mem.splitScalar(u8, scope_text, ' '); while (scopes.next()) |scope| if (std.mem.eql(u8, scope, wanted)) return true; return false;}
fn loopbackClientMetadata(allocator: std.mem.Allocator, client_id: []const u8) !std.json.Parsed(std.json.Value) { const client = try parseLoopbackClientId(allocator, client_id); const Document = struct { client_id: []const u8, client_name: []const u8, scope: []const u8, redirect_uris: []const []const u8, response_types: []const []const u8, grant_types: []const []const u8, token_endpoint_auth_method: []const u8, application_type: []const u8, dpop_bound_access_tokens: bool, }; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try std.json.Stringify.value(Document{ .client_id = client_id, .client_name = "development client", .scope = client.scope, .redirect_uris = client.redirect_uris, .response_types = &.{"code"}, .grant_types = &.{ "authorization_code", "refresh_token" }, .token_endpoint_auth_method = "none", .application_type = "native", .dpop_bound_access_tokens = true, }, .{}, &out.writer); return std.json.parseFromSlice(std.json.Value, allocator, out.written(), .{ .allocate = .alloc_always });}
fn clientMetadata(allocator: std.mem.Allocator, client_id: []const u8) !std.json.Parsed(std.json.Value) { if (isLoopbackClientId(client_id)) return loopbackClientMetadata(allocator, client_id); return fetchJson(allocator, client_id, 256 * 1024);}
fn clientMetadataErrorDescription(err: anyerror) []const u8 { return switch (err) { error.InvalidLoopbackClientId => "Invalid loopback client_id", else => "Could not fetch client metadata", };}
fn parWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const response_type = try requireParam(request, params, allocator, "response_type"); if (!std.mem.eql(u8, response_type, "code")) return oauthError(request, .bad_request, "unsupported_response_type", "Only response_type=code is supported"); const client_id = try requireParam(request, params, allocator, "client_id"); const client_metadata_doc = clientMetadata(allocator, client_id) catch |err| { log.err("oauth par rejected metadata_fetch_failed client={s} err={s}\n", .{ client_id, @errorName(err) }); return oauthError(request, .bad_request, "invalid_client", clientMetadataErrorDescription(err)); }; const client_metadata = ClientMetadata{ .value = client_metadata_doc.value }; if ((zat.json.getBool(client_metadata.value, "dpop_bound_access_tokens") orelse false) != true) { return oauthError(request, .bad_request, "invalid_client", "dpop_bound_access_tokens must be true"); } try requireClientAuth(request, allocator, params, client_id, client_metadata.value); const redirect_uri = try requireParam(request, params, allocator, "redirect_uri"); if (!client_metadata.redirectUriRegistered(redirect_uri)) { return oauthError(request, .bad_request, "invalid_request", "redirect_uri not registered for client"); } const code_challenge = try requireParam(request, params, allocator, "code_challenge"); const code_challenge_method = try requireParam(request, params, allocator, "code_challenge_method"); if (!std.mem.eql(u8, code_challenge_method, "S256")) return oauthError(request, .bad_request, "invalid_request", "Only S256 PKCE is supported"); const scope = try params.value(allocator, "scope") orelse "atproto"; try validateScope(request, allocator, scope, client_metadata.registeredScope()); const state = try params.value(allocator, "state") orelse ""; const response_mode = try params.value(allocator, "response_mode") orelse "query"; if (!validResponseMode(response_mode)) return oauthError(request, .bad_request, "invalid_request", "Invalid response_mode"); const login_hint = try params.value(allocator, "login_hint"); const dpop_jkt_param = try params.value(allocator, "dpop_jkt"); const proof = dpop.verifyRequest(allocator, request, null, dpop_jkt_param) catch |err| { return handleAuthorizationDpopError(request, allocator, err); }; const dpop_jkt = dpop_jkt_param orelse proof.jkt; if (try params.value(allocator, "prompt")) |prompt| { if (!validPrompt(prompt)) return oauthError(request, .bad_request, "invalid_request", "Invalid prompt"); }
const request_id = try store.randomToken(allocator, "", 16); const expires_at = now() + par_expires_in; try store.putOAuthRequest(request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_mode, login_hint, dpop_jkt, expires_at); try store.setOAuthPrompt(request_id, try params.value(allocator, "prompt")); const request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, request_id }); const response = try std.fmt.allocPrint(allocator, "{{\"expires_in\":{d},\"request_uri\":{f}}}", .{ par_expires_in, std.json.fmt(request_uri, .{}) }); try dpop.attachNonce(allocator); try http_api.json(request, .created, response);}
pub fn authorizeGet(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); var request_id_buf: [512]u8 = undefined; const request_id = requestIdFromTarget(request.url.raw, &request_id_buf) orelse return oauthError(request, .bad_request, "invalid_request", "Missing request_uri"); const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri"); if (oauth_request.expires_at < now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri"); const client = clientDisplayInfo(allocator, oauth_request.client_id) catch ClientDisplayInfo{ .name = oauth_request.client_id, .uri = oauth_request.client_id }; if (acceptsJson(request)) { const body = try authRequestInfoJson(allocator, oauth_request, client); return http_api.json(request, .ok, body); } const scopes_html = scopesHtml(allocator, oauth_request.scope) catch |err| { log.err("oauth authorize scope_html failed client={s} request={s} scope={s} err={s}\n", .{ oauth_request.client_id, oauth_request.request_id, oauth_request.scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); }; const scopes_summary = scopeSummary(allocator, oauth_request.scope) catch |err| { log.err("oauth authorize scope_summary failed client={s} request={s} scope={s} err={s}\n", .{ oauth_request.client_id, oauth_request.request_id, oauth_request.scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to summarize requested permission set"); }; const issuer = config.publicUrl(); const passkey_available = try passkeyLoginAvailable(allocator, oauth_request.login_hint); const remembered = try rememberedAccount(request, allocator, oauth_request); const auth_method_html = if (remembered != null) try std.fmt.allocPrint(allocator, \\<input type="hidden" name="csrf" value="{s}"> \\<p class="security-link">This browser remembers your account. Review the requested access before continuing.</p> \\<button name="browser_action" value="continue">authorize</button> \\<button class="deny" name="deny" value="1" formnovalidate>deny</button> \\<button class="deny" name="browser_action" value="forget" formnovalidate>forget this browser / use another account</button> , .{&browser_session.csrf(browser_session.token(request).?, oauth_request.request_id)}) else try authMethodHtml(allocator, passkey_available); const html = try std.fmt.allocPrint(allocator, \\<!doctype html> \\<html lang="en"> \\<head> \\<meta charset="utf-8"> \\<meta name="viewport" content="width=device-width, initial-scale=1"> \\<title>Sign in to zds</title> \\<style> \\:root{{color-scheme:dark;--bg:#070807;--panel:#101210;--panel-2:#151915;--line:#273026;--text:#f0f1ec;--muted:#aaa69b;--dim:#69645c;--accent:#91adff;--green:#39c178;--pink:#f05f8e;--danger:#ffb4a8;--focus:#c9d7ff;--field:#121510}} \\@media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--panel-2:#ece8dd;--line:#d8d0c0;--text:#161412;--muted:#625c53;--dim:#8f8679;--accent:#315dcb;--green:#1b7340;--pink:#b72e5c;--danger:#9f1d1d;--focus:#163f9f;--field:#fffaf1;color-scheme:light}}}} \\*{{box-sizing:border-box}} html{{min-height:100%;background:var(--bg)}} body{{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} \\a{{color:inherit}} a:focus-visible,button:focus-visible,input:focus-visible{{outline:2px solid var(--focus);outline-offset:3px}} \\.shell{{width:min(100%,780px);margin:0 auto;padding:24px 16px 34px}} \\.brand{{display:inline-flex;align-items:center;color:var(--text);font-size:14px;font-weight:700;margin:0 0 54px;text-decoration:none}} \\.hero{{margin:0 0 26px}} \\.kicker{{margin:0 0 12px;color:var(--green);font-size:13px}} \\h1{{font-size:clamp(42px,15vw,82px);line-height:.9;margin:0;letter-spacing:0;color:var(--text);font-weight:800}} \\.lede{{margin:18px 0 0;max-width:48ch;color:var(--muted);font-size:15px}} \\.lede strong{{color:var(--text);font-weight:800}} \\p{{margin:0;color:var(--muted)}} label{{display:block;font-weight:700;margin:18px 0 7px;color:var(--text)}} \\input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:10px;padding:12px 13px}} \\input:focus{{border-color:var(--accent)}} input:-webkit-autofill{{box-shadow:0 0 0 1000px var(--field) inset;-webkit-text-fill-color:var(--text);caret-color:var(--text)}} input:autofill{{box-shadow:0 0 0 1000px var(--field) inset}} \\button{{width:100%;margin-top:24px;border:1px solid color-mix(in srgb,var(--accent) 64%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:780;padding:12px 14px;cursor:pointer;box-shadow:0 10px 28px color-mix(in srgb,var(--accent) 18%,transparent)}} \\button:hover{{filter:brightness(1.05)}} \\.stack{{display:grid;gap:18px}} \\.panel{{border:1px solid var(--line);border-radius:8px;background:var(--panel);overflow:hidden}} \\.client{{padding:15px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--pink) 8%,transparent),transparent 44%),var(--panel)}} \\.client-label{{display:block;color:var(--dim);font-size:11px;font-weight:700;text-transform:lowercase;margin-bottom:5px}} \\.client-name{{display:block;font-size:19px;font-weight:780;line-height:1.25;color:var(--text)}} \\.client-uri{{display:block;margin-top:3px;color:var(--muted);overflow-wrap:anywhere;font-size:12px;text-decoration:none}} \\.client-uri:hover{{color:var(--green)}} \\.summary{{padding:14px 15px;color:var(--muted);border-top:1px solid var(--line)}} \\.summary strong{{color:var(--text)}} \\details{{border:1px solid var(--line);border-radius:8px;background:transparent;overflow:hidden}} summary{{cursor:pointer;padding:13px 15px;font-weight:760;color:var(--text)}} summary::marker{{color:var(--accent)}} \\.scopes{{margin:0;padding:0 15px 14px;list-style:none;display:grid;gap:9px}} \\.scope{{display:grid;gap:5px;border-top:1px solid var(--line);padding-top:10px}} \\.permission-set{{gap:7px}} \\.permission-title{{display:block;color:var(--text);font-size:15px;font-weight:780;line-height:1.35}} \\.scope-code{{display:inline-block;width:fit-content;max-width:100%;overflow-wrap:anywhere;border:1px solid color-mix(in srgb,var(--line) 86%,white 6%);border-radius:7px;background:color-mix(in srgb,var(--field) 88%,transparent);padding:3px 7px;font:13px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text)}} \\.scope-text{{display:block;color:var(--muted);font-size:13px}} \\.scope-tech{{margin-top:3px;border:0;border-radius:0;background:transparent;overflow:visible}} \\.scope-tech summary{{padding:0;color:var(--dim);font-size:12px;font-weight:700}} \\.scope-tech .scope-code{{margin-top:7px}} \\.scope-expanded{{display:block;width:fit-content;max-width:100%;margin-top:5px;overflow-wrap:anywhere;color:var(--dim);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}} \\.inline-code{{display:inline-block;border:1px solid color-mix(in srgb,var(--line) 82%,white 4%);border-radius:5px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:1px 5px;color:var(--text);font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}} \\.remember{{display:flex;gap:10px;align-items:center;font-size:13px;font-weight:400}}.remember input{{width:auto}} \\form{{border-top:1px solid var(--line);padding:0 15px 15px}} \\.actions{{display:grid;grid-template-columns:1fr 1fr;gap:12px}} \\.deny{{background:transparent;color:var(--danger);border:1px solid var(--line);box-shadow:none}} \\.password-fallback{{margin-top:16px;border-color:var(--line);background:color-mix(in srgb,var(--panel-2) 46%,transparent)}} \\.password-fallback summary{{padding:11px 13px;color:var(--muted);font-size:13px}} \\.password-fallback .password-inner{{padding:0 13px 13px}} \\.password-primary{{margin-top:16px;border-top:1px solid var(--line);padding-top:2px}} \\.security-link{{margin-top:12px;font-size:12px;color:var(--dim)}}.security-link a{{color:var(--green)}} \\.foot{{font-size:12px;margin:16px 0 0;color:var(--dim);overflow-wrap:anywhere}} \\@media (min-width:680px){{.stack{{grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);align-items:start}}.auth{{grid-column:1 / -1}}}} \\@media (max-width:520px){{.shell{{padding:18px 14px 26px}}.brand{{margin-bottom:42px}}.actions{{grid-template-columns:1fr}}.deny{{order:2;margin-top:0}}button{{margin-top:18px}}}} \\</style> \\</head> \\<body> \\<main class="shell"> \\<a class="brand" href="/">zds</a> \\<section class="hero"> \\<p class="kicker">oauth authorization</p> \\<h1>continue</h1> \\<p class="lede"><strong>{s}</strong> wants to use this account on {s}.</p> \\</section> \\<div class="stack"> \\<section class="panel client"><span class="client-label">application</span><span class="client-name">{s}</span><a class="client-uri" href="{s}">{s}</a></section> \\<section class="panel auth"> \\<div class="summary">{s}</div> \\<details><summary>requested access</summary><ul class="scopes">{s}</ul></details> \\<form method="post" action="/oauth/authorize"> \\<input type="hidden" name="request_uri" value="{s}{s}"> \\<label for="username">account</label> \\<input id="username" name="username" autocomplete="username" placeholder="bufo.uk" value="{s}" {s} autofocus> \\{s} \\</form> \\</section> \\</div> \\<p class="foot">{s}</p> \\</main> \\<script> \\const b64ToBuf=(v)=>{{const b64=v.replace(/-/g,'+').replace(/_/g,'/');const bin=atob(b64+'==='.slice((b64.length+3)%4));const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}}; \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); \\const prepGet=(o)=>{{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.allowCredentials=(p.allowCredentials||[]).map(c=>({{...c,id:b64ToBuf(c.id)}}));return o}}; \\const serializeGet=(c)=>({{id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{{clientDataJSON:bufToB64(c.response.clientDataJSON),authenticatorData:bufToB64(c.response.authenticatorData),signature:bufToB64(c.response.signature),userHandle:c.response.userHandle?bufToB64(c.response.userHandle):null}}}}); \\document.querySelector('#passkey-login')?.addEventListener('click',async()=>{{const button=document.querySelector('#passkey-login');const label='continue with passkey';try{{const request_uri=document.querySelector('input[name=request_uri]').value;const username=document.querySelector('#username').value;button.disabled=true;button.textContent='checking passkeys...';const options=await fetch('/oauth/passkey/options',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{request_uri,identifier:username}})}}).then(async r=>{{if(!r.ok)throw new Error((await r.json()).error||'passkey unavailable');return r.json()}});button.textContent='waiting for browser prompt...';const credential=await navigator.credentials.get(prepGet(options));const result=await fetch('/oauth/passkey/finish',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{request_uri,credential:serializeGet(credential)}})}}).then(async r=>{{if(!r.ok)throw new Error((await r.json()).error||'passkey login failed');return r.json()}});location.href=result.redirect_uri}}catch(err){{button.disabled=false;button.textContent=err.message||label;setTimeout(()=>button.textContent=label,2800)}}}}); \\</script> \\</body> \\</html> , .{ try htmlEscape(allocator, client.name), try htmlEscape(allocator, displayHost(issuer)), try htmlEscape(allocator, client.name), try htmlEscape(allocator, client.uri), try htmlEscape(allocator, client.uri), scopes_summary, scopes_html, request_uri_prefix, oauth_request.request_id, try htmlEscape(allocator, if (remembered) |user| user.handle else oauth_request.login_hint orelse ""), if (remembered != null) "readonly" else "", auth_method_html, try htmlEscape(allocator, issuer), }); try respondHtml(request, .ok, html);}
fn authMethodHtml(allocator: std.mem.Allocator, passkey_available: bool) ![]const u8 { if (passkey_available) { return allocator.dupe(u8, \\<button id="passkey-login" type="button">continue with passkey</button> \\<details class="password-fallback"> \\<summary>use password instead</summary> \\<div class="password-inner"> \\<label for="password">password</label> \\<input id="password" name="password" type="password" autocomplete="current-password"> \\<label class="remember"><input type="checkbox" name="remember_browser" value="1"> remember this browser for one year</label> \\<div class="actions"> \\<button class="deny" name="deny" value="1" formnovalidate>deny</button> \\<button>authorize</button> \\</div> \\</div> \\</details> ); } return allocator.dupe(u8, \\<div class="password-primary"> \\<label for="password">password</label> \\<input id="password" name="password" type="password" autocomplete="current-password"> \\<label class="remember"><input type="checkbox" name="remember_browser" value="1"> remember this browser for one year</label> \\<div class="actions"> \\<button class="deny" name="deny" value="1" formnovalidate>deny</button> \\<button>authorize</button> \\</div> \\<p class="security-link">Want passkey sign-in next time? Add one from <a href="/account/manage">account security</a>.</p> \\</div> );}
fn passkeyLoginAvailable(allocator: std.mem.Allocator, login_hint: ?[]const u8) !bool { if (login_hint) |identifier| { const account = (try store.findAccount(allocator, identifier)) orelse return false; const passkeys = try store.listPasskeys(allocator, account.did); return passkeys.len != 0; } return true;}
fn rememberedAccount(request: *const http_api.Request, allocator: std.mem.Allocator, oauth_request: store.OAuthRequest) !?auth.Account { if (oauth_request.prompt) |prompt| { var words = std.mem.tokenizeScalar(u8, prompt, ' '); while (words.next()) |word| { if (std.mem.eql(u8, word, "login") or std.mem.eql(u8, word, "create")) return null; } } const user = (try browser_session.account(request, allocator)) orelse return null; if (!try matchesLoginHint(allocator, oauth_request.login_hint, user.did)) return null; return user;}
fn matchesLoginHint(allocator: std.mem.Allocator, hint: ?[]const u8, did: []const u8) !bool { const identifier = hint orelse return true; const hinted = (try store.findAccount(allocator, identifier)) orelse return false; return std.mem.eql(u8, hinted.did, did);}
pub fn authorizePost(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const content_type = http_api.headerValue(request, "content-type") orelse ""; const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); if (isJsonContent(content_type)) { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); return authorizeWithParams(request, allocator, JsonParams{ .json = parsed.value }, true); } return authorizeWithParams(request, allocator, Form{ .body = body }, acceptsJson(request));}
fn authorizeWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype, wants_json: bool) !void { const request_uri = try requireParam(request, params, allocator, "request_uri"); const request_id = requestIdFromUri(request_uri) orelse return oauthError(request, .bad_request, "invalid_request", "Invalid request_uri"); const oauth_request = (try store.getOAuthRequest(allocator, request_id)) orelse return oauthError(request, .bad_request, "invalid_request_uri", "Unknown request_uri"); if (oauth_request.expires_at <= now()) return oauthError(request, .bad_request, "invalid_request_uri", "Expired request_uri"); if (try params.value(allocator, "deny")) |denied| { if (!std.mem.eql(u8, denied, "false")) { const location = try authorizationErrorRedirect(allocator, oauth_request.redirect_uri, "access_denied", "Authorization denied", oauth_request.state, oauth_request.response_mode); return redirectTo(request, location); } } const action = try params.value(allocator, "browser_action"); const user = if (action) |browser_action| remembered: { const submitted = (try params.value(allocator, "csrf")) orelse ""; if (!browser_session.validCsrf(request, request_id, submitted)) return oauthError(request, .forbidden, "access_denied", "Invalid browser consent"); if (std.mem.eql(u8, browser_action, "forget")) { try browser_session.forget(request); const location = try std.fmt.allocPrint(allocator, "/oauth/authorize?request_uri={s}{s}", .{ request_uri_prefix, request_id }); return redirectTo(request, location); } if (!std.mem.eql(u8, browser_action, "continue")) return oauthError(request, .bad_request, "invalid_request", "Invalid browser action"); break :remembered (try rememberedAccount(request, allocator, oauth_request)) orelse return oauthError(request, .unauthorized, "access_denied", "Browser login expired. Please sign in again."); } else password_login: { const identifier = (try params.value(allocator, "username")) orelse try requireParam(request, params, allocator, "identifier"); const password = try requireParam(request, params, allocator, "password"); const user = (try store.findAccount(allocator, identifier)) orelse return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password"); if (!auth.passwordMatches(user, password) or !(try store.accountStatus(user.did)).isActive()) return oauthError(request, .unauthorized, "access_denied", "Invalid identifier or password"); if (!try matchesLoginHint(allocator, oauth_request.login_hint, user.did)) return oauthError(request, .bad_request, "access_denied", "Account does not match login hint"); if (try params.value(allocator, "remember_browser")) |remember| { if (std.mem.eql(u8, remember, "1") or std.mem.eql(u8, remember, "true")) { if (!browser_session.sameOrigin(request)) return oauthError(request, .forbidden, "access_denied", "Remembering a browser requires a same-origin login"); try browser_session.remember(request, allocator, user); } } break :password_login user; }; const code = try store.randomToken(allocator, "", 16); try store.authorizeOAuthRequest(request_id, user.did, code, "password"); const location = try authorizationRedirect(allocator, oauth_request.redirect_uri, code, oauth_request.state, oauth_request.response_mode); if (wants_json) { const response = try std.fmt.allocPrint(allocator, "{{\"redirect_uri\":{f}}}", .{std.json.fmt(location, .{})}); return http_api.json(request, .ok, response); } try redirectTo(request, location);}
pub fn token(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const content_type = http_api.headerValue(request, "content-type") orelse ""; const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); if (isJsonContent(content_type)) { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); return tokenWithParams(request, allocator, JsonParams{ .json = parsed.value }); } return tokenWithParams(request, allocator, Form{ .body = body });}
fn tokenWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const grant_type = try requireParam(request, params, allocator, "grant_type"); if (std.mem.eql(u8, grant_type, "authorization_code")) return authorizationCodeToken(request, allocator, params); if (std.mem.eql(u8, grant_type, "refresh_token")) return refreshToken(request, allocator, params); return oauthError(request, .bad_request, "unsupported_grant_type", "Unsupported grant_type");}
pub fn introspect(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const content_type = http_api.headerValue(request, "content-type") orelse ""; const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); if (isJsonContent(content_type)) { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); return introspectWithParams(request, allocator, JsonParams{ .json = parsed.value }); } return introspectWithParams(request, allocator, Form{ .body = body });}
fn introspectWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const token_text = try requireParam(request, params, allocator, "token"); const row = try store.getOAuthToken(allocator, token_text); if (row == null or row.?.revoked or tokenExpiry(row.?) < now()) return http_api.json(request, .ok, "{\"active\":false}"); const response = try std.fmt.allocPrint(allocator, "{{\"active\":true,\"sub\":{f},\"client_id\":{f},\"scope\":{f},\"exp\":{d}}}", .{ std.json.fmt(row.?.did, .{}), std.json.fmt(row.?.client_id, .{}), std.json.fmt(row.?.scope, .{}), tokenExpiry(row.?), }); try http_api.json(request, .ok, response);}
fn tokenExpiry(row: store.OAuthToken) i64 { return switch (row.kind) { .access => row.access_expires_at, .refresh => row.refresh_expires_at, .previous_refresh => row.previous_refresh_expires_at orelse 0, .used_refresh => 0, };}
pub fn revoke(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const content_type = http_api.headerValue(request, "content-type") orelse ""; const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); if (isJsonContent(content_type)) { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); return revokeWithParams(request, allocator, JsonParams{ .json = parsed.value }); } return revokeWithParams(request, allocator, Form{ .body = body });}
fn revokeWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const token_text = try requireParam(request, params, allocator, "token"); try store.revokeOAuthToken(token_text); try http_api.json(request, .ok, "{}");}
fn authorizationCodeToken(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const code = try requireParam(request, params, allocator, "code"); const redirect_uri = try requireParam(request, params, allocator, "redirect_uri"); const code_verifier = try requireParam(request, params, allocator, "code_verifier"); const client_id = try requireParam(request, params, allocator, "client_id"); const client_metadata = try fetchClientMetadataForAuth(request, allocator, client_id); try requireClientAuth(request, allocator, params, client_id, client_metadata.value); const oauth_request = (try store.getOAuthRequestByCode(allocator, code)) orelse { log.err("oauth code grant invalid_code client={s} code_len={d}\n", .{ client_id, code.len }); return oauthError(request, .bad_request, "invalid_grant", "Invalid code"); }; if (oauth_request.expires_at < now()) { log.err("oauth code grant expired request={s} client={s} expires_at={d}\n", .{ oauth_request.request_id, client_id, oauth_request.expires_at }); return oauthError(request, .bad_request, "invalid_grant", "Expired code"); } if (!std.mem.eql(u8, redirect_uri, oauth_request.redirect_uri) or !std.mem.eql(u8, client_id, oauth_request.client_id)) { log.err("oauth code grant mismatch request={s} row_client={s} token_client={s} row_redirect={s} token_redirect={s}\n", .{ oauth_request.request_id, oauth_request.client_id, client_id, oauth_request.redirect_uri, redirect_uri }); return oauthError(request, .bad_request, "invalid_grant", "Mismatched token request"); } if (!try pkceMatches(allocator, code_verifier, oauth_request.code_challenge)) { log.err("oauth code grant pkce_reject request={s} client={s} verifier_len={d}\n", .{ oauth_request.request_id, client_id, code_verifier.len }); return oauthError(request, .bad_request, "invalid_grant", "Invalid PKCE verifier"); } const did = oauth_request.sub orelse { log.err("oauth code grant unauthorized_code request={s} client={s}\n", .{ oauth_request.request_id, client_id }); return oauthError(request, .bad_request, "invalid_grant", "Code was not authorized"); }; const account = (try store.findAccount(allocator, did)) orelse { log.err("oauth code grant account_not_found request={s} client={s} did={s}\n", .{ oauth_request.request_id, client_id, did }); return oauthError(request, .bad_request, "invalid_grant", "Account not found"); }; const expected_jkt = oauth_request.dpop_jkt orelse { log.err("oauth code grant missing_dpop_binding request={s} client={s} did={s}\n", .{ oauth_request.request_id, client_id, did }); return oauthError(request, .bad_request, "invalid_grant", "Authorization request is not DPoP-bound"); }; _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { log.err("oauth code grant dpop_reject request={s} client={s} did={s} err={s}\n", .{ oauth_request.request_id, client_id, did, @errorName(err) }); return handleAuthorizationDpopError(request, allocator, err); }; if (!try store.consumeOAuthCode(oauth_request.request_id, code)) { log.err("oauth code grant replay request={s} client={s} did={s}\n", .{ oauth_request.request_id, client_id, did }); return oauthError(request, .bad_request, "invalid_grant", "Invalid code"); } try issueTokenResponse(request, allocator, account, oauth_request.client_id, oauth_request.scope, null, oauth_request.dpop_jkt, oauth_request.auth_method); log.info("oauth code grant issued request={s} client={s} did={s} auth_method={s}\n", .{ oauth_request.request_id, client_id, did, oauth_request.auth_method orelse "unknown" });}
fn refreshToken(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { const refresh = try requireParam(request, params, allocator, "refresh_token"); const client_id = try requireParam(request, params, allocator, "client_id"); const client_metadata = try fetchClientMetadataForAuth(request, allocator, client_id); try requireClientAuth(request, allocator, params, client_id, client_metadata.value); const token_row = (try store.getOAuthToken(allocator, refresh)) orelse { log.err("oauth refresh invalid_grant: refresh token not found token_len={d} client={s}\n", .{ refresh.len, client_id }); return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); }; const client_id_match = std.mem.eql(u8, token_row.client_id, client_id); const ts = now(); if (token_row.kind == .used_refresh) { log.err("oauth refresh invalid_grant: used refresh token replayed family_id={s} did={s} client={s}\n", .{ token_row.family_id, token_row.did, client_id }); try store.revokeOAuthTokenFamily(token_row.family_id); return oauthError(request, .bad_request, "invalid_grant", "Refresh token replayed"); } if (token_row.kind == .previous_refresh and tokenExpiry(token_row) < ts) { log.err("oauth refresh invalid_grant: previous refresh token replayed after grace family_id={s} did={s} client={s}\n", .{ token_row.family_id, token_row.did, client_id }); try store.revokeOAuthTokenFamily(token_row.family_id); return oauthError(request, .bad_request, "invalid_grant", "Refresh token replayed"); } if (token_row.revoked or !client_id_match or tokenExpiry(token_row) < ts) { log.err("oauth refresh invalid_grant: kind={s} revoked={} token_expires_at={d} now={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ @tagName(token_row.kind), token_row.revoked, tokenExpiry(token_row), ts, client_id_match, token_row.client_id, client_id, token_row.did }); return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); } if (token_row.kind == .access) return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); const expected_jkt = token_row.dpop_jkt orelse return oauthError(request, .bad_request, "invalid_grant", "Refresh token is not DPoP-bound"); _ = dpop.verifyRequest(allocator, request, null, expected_jkt) catch |err| { return handleAuthorizationDpopError(request, allocator, err); }; if (token_row.kind == .previous_refresh) return issueExistingTokenResponse(request, allocator, token_row); const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found"); try issueTokenResponse(request, allocator, account, token_row.client_id, token_row.scope, refresh, token_row.dpop_jkt, token_row.auth_method);}
fn issueExistingTokenResponse(request: *http_api.Request, allocator: std.mem.Allocator, row: store.OAuthToken) !void { if (row.dpop_jkt != null) try dpop.attachNonce(allocator); const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":\"DPoP\",\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ std.json.fmt(row.access_token, .{}), std.json.fmt(row.refresh_token, .{}), @max(@as(i64, 0), row.access_expires_at - now()), std.json.fmt(row.scope, .{}), std.json.fmt(row.did, .{}), }); try http_api.json(request, .ok, body);}
fn issueTokenResponse( request: *http_api.Request, allocator: std.mem.Allocator, account: auth.Account, client_id: []const u8, scope: []const u8, revoke_old: ?[]const u8, dpop_jkt: ?[]const u8, auth_method: ?[]const u8,) !void { const access = if (dpop_jkt) |jkt| try auth.createDpopSessionJwt(allocator, "access", account, jkt) else try auth.createSessionJwt(allocator, "access", account); const refresh = try auth.createSessionJwt(allocator, "refresh", account); const access_expires_at = now() + access_token_expires_in; const refresh_expires_at = now() + refresh_token_expires_in; const token_scope = permission_sets.expandScopes(allocator, scope, account.did) catch |err| { log.err("oauth token scope_expand failed client={s} did={s} scope={s} err={s}\n", .{ client_id, account.did, scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); }; if (!config.permissionedData() and permission_sets.containsSpaceScope(token_scope)) { return oauthError(request, .bad_request, "invalid_scope", permissioned_data_disabled_message); } if (revoke_old) |old| { try store.rotateOAuthToken(old, access, refresh, access_expires_at, refresh_expires_at, now() + previous_refresh_grace_in); } else { try store.putOAuthToken(account.did, client_id, token_scope, access, refresh, access_expires_at, refresh_expires_at, dpop_jkt, auth_method); } if (dpop_jkt != null) try dpop.attachNonce(allocator); const token_type = if (dpop_jkt != null) "DPoP" else "Bearer"; const body = try std.fmt.allocPrint(allocator, "{{\"access_token\":{f},\"refresh_token\":{f},\"token_type\":{f},\"expires_in\":{d},\"scope\":{f},\"sub\":{f}}}", .{ std.json.fmt(access, .{}), std.json.fmt(refresh, .{}), std.json.fmt(token_type, .{}), access_token_expires_in, std.json.fmt(token_scope, .{}), std.json.fmt(account.did, .{}), }); try http_api.json(request, .ok, body);}
fn requireClientAuth(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype, client_id: []const u8, metadata: std.json.Value) !void { const method = zat.json.getString(metadata, "token_endpoint_auth_method") orelse "none"; if (std.mem.eql(u8, method, "none")) return; if (!std.mem.eql(u8, method, "private_key_jwt")) { log.err("oauth client_auth rejected unsupported_method client={s} method={s}\n", .{ client_id, method }); return oauthError(request, .bad_request, "invalid_client", "Unsupported client authentication method"); }
const assertion_type = try params.value(allocator, "client_assertion_type") orelse { log.err("oauth client_auth rejected missing_assertion_type client={s}\n", .{client_id}); return oauthError(request, .bad_request, "invalid_client", "Missing client assertion type"); }; if (!std.mem.eql(u8, assertion_type, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")) { log.err("oauth client_auth rejected unsupported_assertion_type client={s} type={s}\n", .{ client_id, assertion_type }); return oauthError(request, .bad_request, "invalid_client", "Unsupported client assertion type"); } const assertion = try params.value(allocator, "client_assertion") orelse { log.err("oauth client_auth rejected missing_assertion client={s}\n", .{client_id}); return oauthError(request, .bad_request, "invalid_client", "Missing client assertion"); }; verifyClientAssertion(allocator, metadata, client_id, assertion) catch |err| { log.err("oauth client_auth rejected invalid_assertion client={s} err={s}\n", .{ client_id, @errorName(err) }); return oauthError(request, .bad_request, "invalid_client", "Invalid client assertion"); };}
fn fetchClientMetadataForAuth(request: *http_api.Request, allocator: std.mem.Allocator, client_id: []const u8) !std.json.Parsed(std.json.Value) { const metadata = clientMetadata(allocator, client_id) catch |err| { log.err("oauth client_auth rejected metadata_fetch_failed client={s} err={s}\n", .{ client_id, @errorName(err) }); try oauthError(request, .bad_request, "invalid_client", clientMetadataErrorDescription(err)); return error.HandledResponse; }; if ((zat.json.getBool(metadata.value, "dpop_bound_access_tokens") orelse false) != true) { try oauthError(request, .bad_request, "invalid_client", "dpop_bound_access_tokens must be true"); return error.HandledResponse; } return metadata;}
fn fetchJson(allocator: std.mem.Allocator, url: []const u8, max_response_size: usize) !std.json.Parsed(std.json.Value) { if (try client_document_cache.get(allocator, url, now())) |body| { return try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); }
var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = try transport.fetch(.{ .url = url, .method = .GET, .accept = "application/json", .max_response_size = max_response_size, }); const status_code = @backingInt(result.status); if (status_code < 200 or status_code >= 300) return error.HttpStatus; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); client_document_cache.put(url, result.body, now() + client_document_ttl_seconds) catch {}; return parsed;}
fn verifyClientAssertion(allocator: std.mem.Allocator, metadata: std.json.Value, client_id: []const u8, assertion: []const u8) !void { var parts = std.mem.splitScalar(u8, assertion, '.'); const header_part = parts.next() orelse return error.InvalidJwt; const payload_part = parts.next() orelse return error.InvalidJwt; const signature_part = parts.next() orelse return error.InvalidJwt; if (parts.next() != null) return error.InvalidJwt; const signing_input_len = header_part.len + 1 + payload_part.len; if (assertion.len < signing_input_len or assertion[header_part.len] != '.') return error.InvalidJwt; const signing_input = assertion[0..signing_input_len];
const header_json = try zat.jwt.base64UrlDecode(allocator, header_part); defer allocator.free(header_json); const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); defer header.deinit(); const alg = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt; const jwt_alg = zat.jwt.Algorithm.fromString(alg) orelse return error.InvalidJwt; const kid = zat.json.getString(header.value, "kid");
const payload_json = try zat.jwt.base64UrlDecode(allocator, payload_part); defer allocator.free(payload_json); const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); defer payload.deinit(); const iss = zat.json.getString(payload.value, "iss") orelse return error.InvalidJwt; const sub = zat.json.getString(payload.value, "sub") orelse return error.InvalidJwt; const aud = zat.json.getString(payload.value, "aud") orelse return error.InvalidJwt; const exp = zat.json.getInt(payload.value, "exp"); const iat = zat.json.getInt(payload.value, "iat"); _ = zat.json.getString(payload.value, "jti") orelse return error.InvalidJwt; if (!std.mem.eql(u8, iss, client_id) or !std.mem.eql(u8, sub, client_id)) return error.InvalidJwt; if (!std.mem.eql(u8, aud, config.publicUrl())) { log.err("oauth client_assertion aud_mismatch client={s} aud={s} expected={s}\n", .{ client_id, aud, config.publicUrl() }); return error.AudienceMismatch; } if (!clientAssertionTimestampIsFresh(now(), exp, iat)) { log.err("oauth client_assertion stale client={s} exp={?d} iat={?d} now={d}\n", .{ client_id, exp, iat, now() }); return error.StaleClientAssertion; }
var fetched_jwks: ?std.json.Parsed(std.json.Value) = null; defer if (fetched_jwks) |parsed| parsed.deinit(); const client_keys = switch (try clientJwksSource(metadata)) { .inline_keys => |keys| keys, .uri => |jwks_uri| blk: { fetched_jwks = try fetchJson(allocator, jwks_uri, 256 * 1024); break :blk fetched_jwks.?.value; }, }; const public_key = try clientPublicKeyFromJwks(allocator, client_keys, kid, alg); defer allocator.free(public_key); const signature = try zat.jwt.base64UrlDecode(allocator, signature_part); defer allocator.free(signature); try zat.jwt.verifyJose(jwt_alg, signing_input, signature, public_key);}
const ClientJwksSource = union(enum) { inline_keys: std.json.Value, uri: []const u8,};
/// client metadata carries its signing keys either inline as `jwks` or by/// reference as `jwks_uri`; the reference provider treats the two as mutually/// exclusive and requires one of them for private_key_jwtfn clientJwksSource(metadata: std.json.Value) !ClientJwksSource { const object = switch (metadata) { .object => |object| object, else => return error.InvalidClientMetadata, }; const inline_keys = object.get("jwks"); const uri = zat.json.getString(metadata, "jwks_uri"); if (inline_keys != null and uri != null) return error.InvalidClientMetadata; if (inline_keys) |keys| return .{ .inline_keys = keys }; if (uri) |value| return .{ .uri = value }; return error.InvalidClientMetadata;}
fn clientPublicKeyFromJwks(allocator: std.mem.Allocator, jwks_doc: std.json.Value, kid: ?[]const u8, alg: []const u8) ![]u8 { const keys = zat.json.getArray(jwks_doc, "keys") orelse return error.InvalidJwks; for (keys) |key| { if (kid) |wanted| { const key_kid = zat.json.getString(key, "kid") orelse continue; if (!std.mem.eql(u8, key_kid, wanted)) continue; } if (!std.mem.eql(u8, zat.json.getString(key, "kty") orelse "", "EC")) continue; const crv = zat.json.getString(key, "crv") orelse continue; if (std.mem.eql(u8, alg, "ES256") and !std.mem.eql(u8, crv, "P-256")) continue; if (std.mem.eql(u8, alg, "ES256K") and !std.mem.eql(u8, crv, "secp256k1")) continue; const x = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "x") orelse return error.InvalidJwks); defer allocator.free(x); const y = try zat.jwt.base64UrlDecode(allocator, zat.json.getString(key, "y") orelse return error.InvalidJwks); defer allocator.free(y); if (x.len != 32 or y.len != 32) return error.InvalidJwks; const public_key = try allocator.alloc(u8, 33); public_key[0] = if ((y[31] & 1) == 1) 0x03 else 0x02; @memcpy(public_key[1..33], x); return public_key; } return error.KeyNotFound;}
const Form = struct { body: []const u8,
fn value(self: Form, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 { var params = std.mem.splitScalar(u8, self.body, '&'); while (params.next()) |param| { const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue; var name_buf: [256]u8 = undefined; const decoded_name = http_api.percentDecode(param[0..eq], &name_buf) catch continue; if (!std.mem.eql(u8, decoded_name, name)) continue; const out = try allocator.alloc(u8, param.len - eq); return try http_api.percentDecode(param[eq + 1 ..], out); } return null; }};
const JsonParams = struct { json: std.json.Value,
fn value(self: JsonParams, allocator: std.mem.Allocator, name: []const u8) !?[]const u8 { if (zat.json.getBool(self.json, name)) |boolean| return if (boolean) "true" else "false"; const string = zat.json.getString(self.json, name) orelse return null; return try allocator.dupe(u8, string); }};
fn requireForm(request: *http_api.Request, form: Form, allocator: std.mem.Allocator, name: []const u8) ![]const u8 { return requireParam(request, form, allocator, name);}
fn requireParam(request: *http_api.Request, params: anytype, allocator: std.mem.Allocator, name: []const u8) ![]const u8 { return (try params.value(allocator, name)) orelse { try oauthError(request, .bad_request, "invalid_request", "Missing required field"); return error.MissingFormField; };}
fn isJsonContent(content_type: []const u8) bool { return std.mem.indexOf(u8, content_type, "application/json") != null;}
fn requestIdFromTarget(target: []const u8, out: []u8) ?[]const u8 { const value = http_api.queryParam(target, "request_uri", out) orelse return null; return requestIdFromUri(value);}
fn requestIdFromUri(request_uri: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, request_uri, request_uri_prefix)) return null; return request_uri[request_uri_prefix.len..];}
fn authorizationRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, code: []const u8, state: []const u8, response_mode: []const u8) ![]const u8 { const sep = authorizationResponseSeparator(redirect_uri, response_mode); const code_enc = try percentEncode(allocator, code); const state_enc = try percentEncode(allocator, state); const iss_enc = try percentEncode(allocator, config.publicUrl()); return std.fmt.allocPrint(allocator, "{s}{s}code={s}&state={s}&iss={s}", .{ redirect_uri, sep, code_enc, state_enc, iss_enc });}
fn authorizationErrorRedirect(allocator: std.mem.Allocator, redirect_uri: []const u8, err: []const u8, description: []const u8, state: []const u8, response_mode: []const u8) ![]const u8 { const sep = authorizationResponseSeparator(redirect_uri, response_mode); return std.fmt.allocPrint(allocator, "{s}{s}error={s}&error_description={s}&state={s}&iss={s}", .{ redirect_uri, sep, try percentEncode(allocator, err), try percentEncode(allocator, description), try percentEncode(allocator, state), try percentEncode(allocator, config.publicUrl()), });}
fn authorizationResponseSeparator(redirect_uri: []const u8, response_mode: []const u8) []const u8 { if (std.mem.eql(u8, response_mode, "fragment")) { return if (std.mem.indexOfScalar(u8, redirect_uri, '#') == null) "#" else "&"; } return if (std.mem.indexOfScalar(u8, redirect_uri, '?') == null) "?" else "&";}
fn redirectTo(request: *http_api.Request, location: []const u8) !void { const headers = [_]http.Header{ .{ .name = "location", .value = location }, .{ .name = "access-control-allow-origin", .value = "*" }, .{ .name = "connection", .value = "close" }, }; try http_api.respondNowClose(request, .see_other, "", &headers);}
fn clientDisplayInfo(allocator: std.mem.Allocator, client_id: []const u8) !ClientDisplayInfo { const metadata = try clientMetadata(allocator, client_id); const name = zat.json.getString(metadata.value, "client_name") orelse zat.json.getString(metadata.value, "client_id") orelse client_id; const uri = zat.json.getString(metadata.value, "client_uri") orelse client_id; return .{ .name = try allocator.dupe(u8, name), .uri = try allocator.dupe(u8, uri), };}
fn authRequestInfoJson(allocator: std.mem.Allocator, oauth_request: store.OAuthRequest, client: ClientDisplayInfo) ![]const u8 { const Response = struct { request_uri: []const u8, client_id: []const u8, client_name: []const u8, client_uri: []const u8, scope: []const u8, response_mode: []const u8, login_hint: []const u8, }; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try std.json.Stringify.value(Response{ .request_uri = try std.fmt.allocPrint(allocator, "{s}{s}", .{ request_uri_prefix, oauth_request.request_id }), .client_id = oauth_request.client_id, .client_name = client.name, .client_uri = client.uri, .scope = oauth_request.scope, .response_mode = oauth_request.response_mode, .login_hint = oauth_request.login_hint orelse "", }, .{}, &out.writer); return out.toOwnedSlice();}
fn scopesHtml(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); var scopes = std.mem.splitScalar(u8, scope_text, ' '); var any = false; while (scopes.next()) |scope| { if (scope.len == 0) continue; any = true; if (std.mem.startsWith(u8, scope, "include:")) { const permission_set = try permission_sets.resolvePermissionSet(allocator, scope); try out.writer.print( "<li class=\"scope permission-set\"><span class=\"permission-title\">{s}</span><span class=\"scope-text\">{s}</span><details class=\"scope-tech\"><summary>technical scope</summary><code class=\"scope-code\">{s}</code><code class=\"scope-expanded\">{s}</code></details></li>", .{ try htmlEscape(allocator, permission_set.title), try htmlEscape(allocator, permission_set.detail), try htmlEscape(allocator, scope), try htmlEscape(allocator, permission_set.expanded_scope), }, ); continue; } const display = scopeDisplay(scope); const scope_note = try scopeDetailHtml(allocator, scope, display.description); try out.writer.print( "<li class=\"scope\"><code class=\"scope-code\">{s}</code><span class=\"scope-text\">{s}</span></li>", .{ try htmlEscape(allocator, scope), scope_note, }, ); } if (!any) try out.writer.writeAll("<li class=\"scope\"><code class=\"scope-code\">atproto</code><span class=\"scope-text\">sign in as this account.</span></li>"); return out.toOwnedSlice();}
fn scopeSummary(allocator: std.mem.Allocator, scope_text: []const u8) ![]const u8 { var has_signin = false; var has_blob = false; var has_rpc = false; var has_account = false; var has_identity = false; var has_space = false; var permission_titles: std.Io.Writer.Allocating = .init(allocator); defer permission_titles.deinit(); var include_count: usize = 0; var repo_collections: std.Io.Writer.Allocating = .init(allocator); defer repo_collections.deinit(); var repo_count: usize = 0;
var scopes = std.mem.splitScalar(u8, scope_text, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "atproto")) { has_signin = true; } else if (std.mem.startsWith(u8, scope, "include:")) { const permission_set = try permission_sets.resolvePermissionSet(allocator, scope); if (include_count < 2) { if (include_count > 0) try permission_titles.writer.writeAll(", "); try permission_titles.writer.print("<strong>{s}</strong>", .{try htmlEscape(allocator, permission_set.title)}); } include_count += 1; } else if (std.mem.startsWith(u8, scope, "repo:")) { if (repoCollection(scope)) |collection| { if (repo_count < 3) { if (repo_count > 0) try repo_collections.writer.writeAll(", "); try repo_collections.writer.writeAll(try htmlEscape(allocator, collection)); } repo_count += 1; } } else if (std.mem.startsWith(u8, scope, "repo?")) { if (repoQueryCollection(scope)) |collection| { if (repo_count < 3) { if (repo_count > 0) try repo_collections.writer.writeAll(", "); try repo_collections.writer.writeAll(try htmlEscape(allocator, collection)); } repo_count += 1; } } else if (std.mem.startsWith(u8, scope, "blob:")) { has_blob = true; } else if (std.mem.startsWith(u8, scope, "rpc:")) { has_rpc = true; } else if (std.mem.startsWith(u8, scope, "account:")) { has_account = true; } else if (std.mem.startsWith(u8, scope, "identity:")) { has_identity = true; } else if (std.mem.startsWith(u8, scope, "space:")) { has_space = true; } }
var parts: std.Io.Writer.Allocating = .init(allocator); defer parts.deinit(); var count: usize = 0; try appendSummaryPart(&parts.writer, &count, has_signin, "sign you in"); if (include_count > 0) { const suffix = if (include_count > 2) " and more" else ""; const include_part = try std.fmt.allocPrint(allocator, "grant {s}{s}", .{ permission_titles.written(), suffix }); try appendSummaryPart(&parts.writer, &count, true, include_part); } if (repo_count > 0) { const suffix = if (repo_count > 3) " and more" else ""; const repo_part = try std.fmt.allocPrint(allocator, "also access <code>{s}{s}</code>", .{ repo_collections.written(), suffix }); try appendSummaryPart(&parts.writer, &count, true, repo_part); } try appendSummaryPart(&parts.writer, &count, has_blob, "use uploaded blobs"); try appendSummaryPart(&parts.writer, &count, has_rpc, "call XRPC methods"); try appendSummaryPart(&parts.writer, &count, has_account, "manage account settings"); try appendSummaryPart(&parts.writer, &count, has_identity, "manage identity"); try appendSummaryPart(&parts.writer, &count, has_space, "access permissioned spaces"); if (count == 0) return allocator.dupe(u8, "<strong>requested access:</strong> sign in as this account."); return std.fmt.allocPrint(allocator, "<strong>requested access:</strong> {s}.", .{parts.written()});}
fn appendSummaryPart(writer: *std.Io.Writer, count: *usize, enabled: bool, text: []const u8) !void { if (!enabled) return; if (count.* > 0) try writer.writeAll(", "); try writer.writeAll(text); count.* += 1;}
const ScopeDisplay = struct { name: []const u8, description: []const u8,};
fn scopeDisplay(scope: []const u8) ScopeDisplay { if (std.mem.eql(u8, scope, "atproto")) return .{ .name = "AT Protocol access", .description = "Read and write AT Protocol data for this account." }; if (std.mem.eql(u8, scope, "transition:generic")) return .{ .name = "Legacy app access", .description = "Use generic transition APIs during OAuth migration." }; if (std.mem.eql(u8, scope, "transition:email")) return .{ .name = "Email access", .description = "Read account email information exposed by transition APIs." }; if (std.mem.eql(u8, scope, "transition:chat.bsky")) return .{ .name = "Bluesky chat transition access", .description = "Use chat transition APIs requested by compatible clients." }; if (std.mem.startsWith(u8, scope, "repo:")) return .{ .name = "Repository access", .description = "Access records in this account repository." }; if (std.mem.startsWith(u8, scope, "blob:")) return .{ .name = "Blob access", .description = "Access uploaded media and blob data." }; if (std.mem.startsWith(u8, scope, "rpc:")) return .{ .name = "XRPC access", .description = "Call AT Protocol API methods through this account." }; if (std.mem.startsWith(u8, scope, "account:")) return .{ .name = "Account access", .description = "Manage account-level settings requested by the app." }; if (std.mem.startsWith(u8, scope, "identity:")) return .{ .name = "Identity access", .description = "Manage identity information requested by the app." }; if (std.mem.startsWith(u8, scope, "space:")) return .{ .name = "Permissioned space access", .description = "Access private permissioned data spaces requested by the app." }; return .{ .name = "Requested scope", .description = "A scope requested by this OAuth client." };}
fn scopeDetailHtml(allocator: std.mem.Allocator, scope: []const u8, fallback: []const u8) ![]const u8 { if (repoCollection(scope)) |collection| { const action = repoScopeAction(scope); if (action) |value| { return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records with <code class=\"inline-code\">{s}</code> permission.", .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, ); } return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records.", .{try htmlEscape(allocator, collection)}, ); } if (repoQueryCollection(scope)) |collection| { const action = queryParam(scope, "action"); if (action) |value| { return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records with <code class=\"inline-code\">{s}</code> permission.", .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, ); } return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records.", .{try htmlEscape(allocator, collection)}, ); } if (spaceType(scope)) |space_type| { const action = queryParam(scope, "action"); const collection = queryParam(scope, "collection"); if (collection) |collection_value| { if (action) |action_value| { return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records in permissioned space <code class=\"inline-code\">{s}</code> with <code class=\"inline-code\">{s}</code> permission.", .{ try htmlEscape(allocator, collection_value), try htmlEscape(allocator, space_type), try htmlEscape(allocator, action_value) }, ); } return std.fmt.allocPrint( allocator, "Access <code class=\"inline-code\">{s}</code> records in permissioned space <code class=\"inline-code\">{s}</code>.", .{ try htmlEscape(allocator, collection_value), try htmlEscape(allocator, space_type) }, ); } if (action) |action_value| { return std.fmt.allocPrint( allocator, "Access permissioned space <code class=\"inline-code\">{s}</code> with <code class=\"inline-code\">{s}</code> permission.", .{ try htmlEscape(allocator, space_type), try htmlEscape(allocator, action_value) }, ); } return std.fmt.allocPrint( allocator, "Access permissioned space <code class=\"inline-code\">{s}</code>.", .{try htmlEscape(allocator, space_type)}, ); } return htmlEscape(allocator, fallback);}
fn repoCollection(scope: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, scope, "repo:")) return null; const rest = scope["repo:".len..]; const collection = if (std.mem.indexOfScalar(u8, rest, '?')) |idx| rest[0..idx] else rest; if (collection.len == 0 or std.mem.eql(u8, collection, "*")) return null; return collection;}
fn repoQueryCollection(scope: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, scope, "repo?")) return null; return queryParam(scope, "collection");}
fn repoScopeAction(scope: []const u8) ?[]const u8 { if (std.mem.indexOfScalar(u8, scope, '?') == null) return null; return queryParam(scope, "action");}
fn spaceType(scope: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, scope, "space:")) return null; const rest = scope["space:".len..]; const value = if (std.mem.indexOfScalar(u8, rest, '?')) |idx| rest[0..idx] else rest; if (value.len == 0 or std.mem.eql(u8, value, "*")) return null; return value;}
fn queryParam(scope: []const u8, name: []const u8) ?[]const u8 { const query_start = std.mem.indexOfScalar(u8, scope, '?') orelse return null; var parts = std.mem.splitScalar(u8, scope[query_start + 1 ..], '&'); while (parts.next()) |part| { const equals = std.mem.indexOfScalar(u8, part, '=') orelse continue; if (std.mem.eql(u8, part[0..equals], name)) return part[equals + 1 ..]; } return null;}
fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); for (value) |byte| switch (byte) { '&' => try out.writer.writeAll("&"), '<' => try out.writer.writeAll("<"), '>' => try out.writer.writeAll(">"), '"' => try out.writer.writeAll("""), '\'' => try out.writer.writeAll("'"), else => try out.writer.writeByte(byte), }; return out.toOwnedSlice();}
fn displayHost(public_url: []const u8) []const u8 { var value = public_url; if (std.mem.startsWith(u8, value, "https://")) value = value["https://".len..]; if (std.mem.startsWith(u8, value, "http://")) value = value["http://".len..]; if (std.mem.indexOfScalar(u8, value, '/')) |idx| value = value[0..idx]; return value;}
fn pkceMatches(allocator: std.mem.Allocator, verifier: []const u8, challenge: []const u8) !bool { var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(verifier, &digest, .{}); const encoded = try zat.jwt.base64UrlEncode(allocator, &digest); return std.mem.eql(u8, encoded, challenge);}
fn validPrompt(prompt: []const u8) bool { var parts = std.mem.splitScalar(u8, prompt, ' '); while (parts.next()) |part| { if (part.len == 0) continue; if (!(std.mem.eql(u8, part, "none") or std.mem.eql(u8, part, "login") or std.mem.eql(u8, part, "consent") or std.mem.eql(u8, part, "select_account") or std.mem.eql(u8, part, "create"))) return false; } return true;}
fn validResponseMode(response_mode: []const u8) bool { return std.mem.eql(u8, response_mode, "query") or std.mem.eql(u8, response_mode, "fragment");}
const permissioned_data_disabled_message = "Permissioned data (spaces) is not enabled on this server";
fn validateScope(request: *http_api.Request, allocator: std.mem.Allocator, scope_text: []const u8, client_scope_text: ?[]const u8) !void { var scopes = std.mem.splitScalar(u8, scope_text, ' '); var saw_scope = false; var has_transition = false; var has_granular = false; while (scopes.next()) |scope| { if (scope.len == 0) continue; saw_scope = true; if (client_scope_text) |client_scopes| { if (!scopeRegistered(client_scopes, scope)) { return oauthError(request, .bad_request, "invalid_scope", "Scope not registered for this client"); } } if (std.mem.eql(u8, scope, "atproto")) continue; if (std.mem.eql(u8, scope, "transition:generic") or std.mem.eql(u8, scope, "transition:email") or std.mem.eql(u8, scope, "transition:chat.bsky")) { has_transition = true; continue; } if (isGranularScope(scope)) { if (std.mem.startsWith(u8, scope, "include:")) { const permission_set = permission_sets.resolvePermissionSet(allocator, scope) catch |err| { log.err("oauth validate include failed scope={s} err={s}\n", .{ scope, @errorName(err) }); return oauthError(request, .bad_request, "invalid_scope", "Failed to resolve requested permission set"); }; if (!config.permissionedData() and permission_sets.containsSpaceScope(permission_set.expanded_scope)) { return oauthError(request, .bad_request, "invalid_scope", permissioned_data_disabled_message); } } else if (!config.permissionedData() and permission_sets.containsSpaceScope(scope)) { return oauthError(request, .bad_request, "invalid_scope", permissioned_data_disabled_message); } has_granular = true; continue; } return oauthError(request, .bad_request, "invalid_scope", "Unsupported scope"); } if (!saw_scope) return oauthError(request, .bad_request, "invalid_scope", "Empty scope"); if (has_transition and has_granular) { return oauthError(request, .bad_request, "invalid_scope", "Cannot mix transition scopes with granular scopes"); }}
fn scopeRegistered(client_scope_text: []const u8, requested_scope: []const u8) bool { var client_scopes = std.mem.splitScalar(u8, client_scope_text, ' '); while (client_scopes.next()) |client_scope| { if (client_scope.len == 0) continue; if (std.mem.eql(u8, client_scope, requested_scope)) return true; if (sameScopeResource(client_scope, requested_scope)) return true; } return false;}
fn sameScopeResource(client_scope: []const u8, requested_scope: []const u8) bool { const client_base = scopeBase(client_scope); const requested_base = scopeBase(requested_scope); const resource = scopeResourceType(client_base); if (!std.mem.eql(u8, resource, scopeResourceType(requested_base))) return false; if (std.mem.eql(u8, resource, "blob")) { return blobResourceCovers(scopeResourceName(client_base), scopeResourceName(requested_base)); } return wildcardResourceCovers(scopeResourceName(client_base), scopeResourceName(requested_base));}
fn scopeBase(scope: []const u8) []const u8 { return if (std.mem.indexOfScalar(u8, scope, '?')) |idx| scope[0..idx] else scope;}
fn scopeResourceType(scope: []const u8) []const u8 { return if (std.mem.indexOfScalar(u8, scope, ':')) |idx| scope[0..idx] else scope;}
fn scopeResourceName(scope: []const u8) []const u8 { return if (std.mem.indexOfScalar(u8, scope, ':')) |idx| scope[idx + 1 ..] else "";}
fn wildcardResourceCovers(allowed: []const u8, requested: []const u8) bool { if (std.mem.eql(u8, allowed, "*")) return true; if (std.mem.eql(u8, allowed, requested)) return true; if (!std.mem.endsWith(u8, allowed, ".*")) return false; const prefix = allowed[0 .. allowed.len - 2]; return std.mem.startsWith(u8, requested, prefix) and requested.len > prefix.len and requested[prefix.len] == '.';}
fn blobResourceCovers(allowed: []const u8, requested: []const u8) bool { if (std.mem.eql(u8, allowed, "*/*")) return true; if (std.mem.eql(u8, allowed, requested)) return true; if (!std.mem.endsWith(u8, allowed, "/*")) return false; const prefix = allowed[0 .. allowed.len - 2]; return std.mem.startsWith(u8, requested, prefix) and requested.len > prefix.len and requested[prefix.len] == '/';}
fn clientAssertionTimestampIsFresh(current_time: i64, exp: ?i64, iat: ?i64) bool { if (exp) |expires_at| return expires_at >= current_time; const issued_at = iat orelse return false; if (issued_at > current_time + 60) return false; return current_time - issued_at <= 300;}
fn isGranularScope(scope: []const u8) bool { return std.mem.startsWith(u8, scope, "repo:") or std.mem.startsWith(u8, scope, "repo?") or std.mem.startsWith(u8, scope, "blob:") or std.mem.startsWith(u8, scope, "rpc:") or std.mem.startsWith(u8, scope, "account:") or std.mem.startsWith(u8, scope, "identity:") or std.mem.startsWith(u8, scope, "include:") or std.mem.startsWith(u8, scope, "space:") or std.mem.startsWith(u8, scope, "space?");}
fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); for (value) |c| { if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_' or c == '.' or c == '~') { try out.writer.writeByte(c); } else { try out.writer.print("%{X:0>2}", .{c}); } } return out.toOwnedSlice();}
fn joinUrl(allocator: std.mem.Allocator, base: []const u8, path: []const u8) ![]const u8 { return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, path });}
fn trimTrailingSlash(value: []const u8) []const u8 { if (value.len > 1 and value[value.len - 1] == '/') return value[0 .. value.len - 1]; return value;}
fn acceptsJson(request: *const http_api.Request) bool { const accept = http_api.headerValue(request, "accept") orelse return false; return std.mem.indexOf(u8, accept, "application/json") != null;}
fn now() i64 { return clock.nowSeconds();}
// writes the error response and then fails with HandledResponse, so a helper// that does `return oauthError(...)` stops its caller too: before this, `try// validateScope(...)` and `try requireClientAuth(...)` returned success after// writing a 400 and the handler went on to overwrite it with a 201/200.fn oauthError(request: *http_api.Request, status: http.Status, err: []const u8, description: []const u8) !void { var buf: [512]u8 = undefined; const body = try std.fmt.bufPrint(&buf, "{{\"error\":{f},\"error_description\":{f}}}", .{ std.json.fmt(err, .{}), std.json.fmt(description, .{}) }); try http_api.json(request, status, body); return error.HandledResponse;}
fn handleAuthorizationDpopError(request: *http_api.Request, allocator: std.mem.Allocator, err: anyerror) !void { return switch (err) { error.UseDpopNonce, error.MissingProof => dpop.challengeAuthorizationServer(request, allocator), error.KeyBindingMismatch => oauthError(request, .bad_request, "invalid_grant", "DPoP proof does not match the expected JKT"), error.Replay, error.InvalidProof => oauthError(request, .unauthorized, "invalid_dpop_proof", "Invalid DPoP proof"), else => err, };}
fn respondHtml(request: *http_api.Request, status: http.Status, body: []const u8) !void { const headers = [_]http.Header{ .{ .name = "content-type", .value = "text/html; charset=utf-8" }, .{ .name = "cache-control", .value = "no-store" }, .{ .name = "x-frame-options", .value = "DENY" }, .{ .name = "content-security-policy", .value = "frame-ancestors 'none'" }, .{ .name = "access-control-allow-origin", .value = "*" }, .{ .name = "connection", .value = "close" }, }; try http_api.respond(request, status, body, &headers);}
test "client assertion accepts exp or recent iat" { try std.testing.expect(clientAssertionTimestampIsFresh(1000, 1060, null)); try std.testing.expect(clientAssertionTimestampIsFresh(1000, null, 940)); try std.testing.expect(!clientAssertionTimestampIsFresh(1000, 999, null)); try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, 699)); try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, 1061)); try std.testing.expect(!clientAssertionTimestampIsFresh(1000, null, null));}
test "granular scopes accept query-form repo permissions" { try std.testing.expect(isGranularScope("repo:*")); try std.testing.expect(isGranularScope("repo?collection=place.stream.live&action=create")); try std.testing.expect(isGranularScope("repo?action=create&collection=place.stream.live")); try std.testing.expect(isGranularScope("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview")); try std.testing.expect(isGranularScope("space:app.bulleted.space?action=read")); try std.testing.expect(isGranularScope("space?type=app.bulleted.space")); try std.testing.expect(!isGranularScope("transition:generic"));}
test "registered client scope matching follows resource base" { try std.testing.expect(scopeRegistered("atproto repo:*?action=create", "repo:*?action=create")); try std.testing.expect(scopeRegistered("atproto repo:*", "repo:*?action=create")); try std.testing.expect(scopeRegistered("atproto repo:*", "repo:earth.cirrus.check.testrecord?action=create")); try std.testing.expect(scopeRegistered("atproto blob:*/*", "blob:image/*")); try std.testing.expect(scopeRegistered("atproto transition:generic", "transition:generic")); try std.testing.expect(!scopeRegistered("atproto repo:app.bsky.feed.post", "repo:earth.cirrus.check.testrecord")); try std.testing.expect(!scopeRegistered("atproto", "transition:generic")); try std.testing.expect(!scopeRegistered("atproto blob:image/*", "blob:*/*"));}
test "native loopback redirect matches any port; everything else stays exact" { try std.testing.expect(loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://127.0.0.1:49152/callback")); try std.testing.expect(loopbackRedirectMatchesAnyPort("http://[::1]/callback", "http://[::1]:8080/callback")); try std.testing.expect(loopbackRedirectMatchesAnyPort("http://127.0.0.1/cb?x=1", "http://127.0.0.1:5/cb?x=1")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1:3000/callback", "http://127.0.0.1:3001/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://127.0.0.1:49152/other")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://127.0.0.1:49152/callback?x=1")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://[::1]:49152/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://127.0.0.10:49152/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://127.0.0.1/callback", "http://127.0.0.1:abc/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("http://localhost/callback", "http://localhost:49152/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("https://127.0.0.1/callback", "https://127.0.0.1:49152/callback")); try std.testing.expect(!loopbackRedirectMatchesAnyPort("https://app.example/callback", "https://app.example:443/callback"));}
test "loopback client ids are virtual: exact http://localhost origin, query-only metadata" { try std.testing.expect(isLoopbackClientId("http://localhost")); try std.testing.expect(isLoopbackClientId("http://localhost/")); try std.testing.expect(isLoopbackClientId("http://localhost?scope=atproto")); try std.testing.expect(isLoopbackClientId("http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A5199%2F")); try std.testing.expect(!isLoopbackClientId("http://localhost:3000")); try std.testing.expect(!isLoopbackClientId("http://localhost/path?scope=atproto")); try std.testing.expect(!isLoopbackClientId("http://localhost.example")); try std.testing.expect(!isLoopbackClientId("https://localhost")); try std.testing.expect(!isLoopbackClientId("http://127.0.0.1?scope=atproto"));
var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator();
const defaults = try parseLoopbackClientId(allocator, "http://localhost"); try std.testing.expectEqualStrings("atproto", defaults.scope); try std.testing.expectEqual(@as(usize, 2), defaults.redirect_uris.len); try std.testing.expectEqualStrings("http://127.0.0.1/", defaults.redirect_uris[0]); try std.testing.expectEqualStrings("http://[::1]/", defaults.redirect_uris[1]);
const configured = try parseLoopbackClientId(allocator, "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A5199%2Fcb&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcb&scope=atproto%20repo%3A*"); try std.testing.expectEqualStrings("atproto repo:*", configured.scope); try std.testing.expectEqual(@as(usize, 2), configured.redirect_uris.len); try std.testing.expectEqualStrings("http://127.0.0.1:5199/cb", configured.redirect_uris[0]); try std.testing.expectEqualStrings("http://[::1]/cb", configured.redirect_uris[1]);
try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?redirect_uri=https%3A%2F%2Fevil.example%2F")); try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?redirect_uri=http%3A%2F%2Flocalhost%3A5199%2F")); try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?scope=repo%3A*")); try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?scope=atproto&scope=atproto")); try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?client_name=x")); try std.testing.expectError(error.InvalidLoopbackClientId, parseLoopbackClientId(allocator, "http://localhost?scope=atproto#frag"));
const document = try loopbackClientMetadata(allocator, "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb"); defer document.deinit(); const client = ClientMetadata{ .value = document.value }; try std.testing.expect(client.redirectUriRegistered("http://127.0.0.1:61234/cb")); try std.testing.expect(!client.redirectUriRegistered("http://[::1]:61234/cb")); try std.testing.expectEqualStrings("atproto", client.registeredScope().?); try std.testing.expectEqualStrings("none", zat.json.getString(document.value, "token_endpoint_auth_method").?); try std.testing.expect(zat.json.getBool(document.value, "dpop_bound_access_tokens").?);}
test "redirect_uri registration honours application_type for the loopback exception" { const native = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"application_type\":\"native\",\"redirect_uris\":[\"http://127.0.0.1/callback\",\"https://app.example/cb\"]}", .{}); defer native.deinit(); const native_client = ClientMetadata{ .value = native.value }; try std.testing.expect(native_client.redirectUriRegistered("http://127.0.0.1:61234/callback")); try std.testing.expect(native_client.redirectUriRegistered("http://127.0.0.1/callback")); try std.testing.expect(native_client.redirectUriRegistered("https://app.example/cb")); try std.testing.expect(!native_client.redirectUriRegistered("https://app.example:8443/cb")); try std.testing.expect(!native_client.redirectUriRegistered("http://127.0.0.1:61234/elsewhere"));
const web = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"redirect_uris\":[\"http://127.0.0.1/callback\"]}", .{}); defer web.deinit(); const web_client = ClientMetadata{ .value = web.value }; try std.testing.expect(web_client.redirectUriRegistered("http://127.0.0.1/callback")); try std.testing.expect(!web_client.redirectUriRegistered("http://127.0.0.1:61234/callback"));}
test "private_key_jwt clients may publish keys inline as jwks or by jwks_uri" { const allocator = std.testing.allocator; const inline_doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks\":{\"keys\":[{\"kty\":\"EC\",\"crv\":\"P-256\",\"kid\":\"k1\",\"x\":\"meMgEL0CxvtJZgInPqXwHPPCczrXIuVhcaHCE1CXfy0\",\"y\":\"meMgEL0CxvtJZgInPqXwHPPCczrXIuVhcaHCE1CXfy0\"}]}}", .{}); defer inline_doc.deinit(); const inline_source = try clientJwksSource(inline_doc.value); try std.testing.expect(inline_source == .inline_keys); const key = try clientPublicKeyFromJwks(allocator, inline_source.inline_keys, "k1", "ES256"); defer allocator.free(key); try std.testing.expectEqual(@as(usize, 33), key.len);
const uri_doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks_uri\":\"https://client.example/jwks.json\"}", .{}); defer uri_doc.deinit(); try std.testing.expectEqualStrings("https://client.example/jwks.json", (try clientJwksSource(uri_doc.value)).uri);
const both = try std.json.parseFromSlice(std.json.Value, allocator, "{\"jwks\":{\"keys\":[]},\"jwks_uri\":\"https://client.example/jwks.json\"}", .{}); defer both.deinit(); try std.testing.expectError(error.InvalidClientMetadata, clientJwksSource(both.value));
const neither = try std.json.parseFromSlice(std.json.Value, allocator, "{\"token_endpoint_auth_method\":\"private_key_jwt\"}", .{}); defer neither.deinit(); try std.testing.expectError(error.InvalidClientMetadata, clientJwksSource(neither.value));}