const 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]` or `http://[::1][:port]`; `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, \\ \\ \\ \\ \\ , .{&browser_session.csrf(browser_session.token(request).?, oauth_request.request_id)}) else try authMethodHtml(allocator, passkey_available); const html = try std.fmt.allocPrint(allocator, \\ \\ \\ \\ \\ \\Sign in to zds \\ \\ \\ \\
\\zds \\
\\

oauth authorization

\\

continue

\\

{s} wants to use this account on {s}.

\\
\\
\\
application{s}{s}
\\
\\
{s}
\\
requested access
    {s}
\\
\\ \\ \\ \\{s} \\
\\
\\
\\

{s}

\\
\\ \\ \\ , .{ 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, \\ \\
\\use password instead \\
\\ \\ \\ \\
\\ \\ \\
\\
\\
); } return allocator.dupe(u8, \\
\\ \\ \\ \\
\\ \\ \\
\\ \\
); } 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_jwt fn 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( "
  • {s}{s}
    technical scope{s}{s}
  • ", .{ 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( "
  • {s}{s}
  • ", .{ try htmlEscape(allocator, scope), scope_note, }, ); } if (!any) try out.writer.writeAll("
  • atprotosign in as this account.
  • "); 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("{s}", .{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 {s}{s}", .{ 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, "requested access: sign in as this account."); return std.fmt.allocPrint(allocator, "requested access: {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 {s} records with {s} permission.", .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, ); } return std.fmt.allocPrint( allocator, "Access {s} records.", .{try htmlEscape(allocator, collection)}, ); } if (repoQueryCollection(scope)) |collection| { const action = queryParam(scope, "action"); if (action) |value| { return std.fmt.allocPrint( allocator, "Access {s} records with {s} permission.", .{ try htmlEscape(allocator, collection), try htmlEscape(allocator, value) }, ); } return std.fmt.allocPrint( allocator, "Access {s} 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 {s} records in permissioned space {s} with {s} permission.", .{ try htmlEscape(allocator, collection_value), try htmlEscape(allocator, space_type), try htmlEscape(allocator, action_value) }, ); } return std.fmt.allocPrint( allocator, "Access {s} records in permissioned space {s}.", .{ try htmlEscape(allocator, collection_value), try htmlEscape(allocator, space_type) }, ); } if (action) |action_value| { return std.fmt.allocPrint( allocator, "Access permissioned space {s} with {s} permission.", .{ try htmlEscape(allocator, space_type), try htmlEscape(allocator, action_value) }, ); } return std.fmt.allocPrint( allocator, "Access permissioned space {s}.", .{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)); }