diff --git a/src/http/api.zig b/src/http/api.zig index 69cd9fc..ad71b7e 100644 --- a/src/http/api.zig +++ b/src/http/api.zig @@ -33,6 +33,18 @@ pub const BearerAccount = struct { oauth_client_id: ?[]const u8 = null, }; +const TokenScheme = enum { + bearer, + dpop, + + fn name(self: TokenScheme) []const u8 { + return switch (self) { + .bearer => "Bearer", + .dpop => "DPoP", + }; + } +}; + pub fn requireBearerAccount(request: *const Request, allocator: std.mem.Allocator) !auth.Account { return (try requireBearerAccountWithScope(request, allocator, "com.atproto.access")).account; } @@ -46,7 +58,7 @@ pub fn requireBearerAccountWithScope(request: *const Request, allocator: std.mem return error.AuthRequired; }; - const token_scheme: enum { bearer, dpop } = + const token_scheme: TokenScheme = if (std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) .bearer else if (std.ascii.startsWithIgnoreCase(auth_header, "dpop ")) @@ -58,6 +70,7 @@ pub fn requireBearerAccountWithScope(request: *const Request, allocator: std.mem const token = std.mem.trim(u8, auth_header[token_start..], " \t"); const claims = auth.claimsFromSessionJwt(allocator, token) orelse { log.err("bearer auth invalid: jwt parse/verify failed token_len={d}\n", .{token.len}); + challengeInvalidToken(allocator, token_scheme, "Token is invalid"); return error.InvalidToken; }; defer allocator.free(claims.did); @@ -86,8 +99,14 @@ pub fn requireBearerAccountWithScope(request: *const Request, allocator: std.mem log.err("oauth auth invalid: non-access token used for resource auth kind={s} did={s}\n", .{ @tagName(row.kind), row.did }); return error.InvalidToken; } - if (row.revoked or row.access_expires_at < ts) { - log.err("bearer auth invalid: oauth row revoked={} access_expires_at={d} now={d} did={s}\n", .{ row.revoked, row.access_expires_at, ts, row.did }); + if (row.revoked) { + log.err("bearer auth invalid: oauth row revoked=true access_expires_at={d} now={d} did={s}\n", .{ row.access_expires_at, ts, row.did }); + challengeInvalidToken(allocator, token_scheme, "Token has been revoked"); + return error.InvalidToken; + } + if (row.access_expires_at < ts) { + log.err("bearer auth invalid: oauth row revoked=false access_expires_at={d} now={d} did={s}\n", .{ row.access_expires_at, ts, row.did }); + challengeInvalidToken(allocator, token_scheme, "Token has expired"); return error.InvalidToken; } if (!std.mem.eql(u8, row.did, claims.did)) { @@ -137,6 +156,21 @@ pub fn requireBearerAccountWithScope(request: *const Request, allocator: std.mem return .{ .account = account, .oauth_scope = null }; } +fn challengeInvalidToken(allocator: std.mem.Allocator, scheme: TokenScheme, description: []const u8) void { + var buf: [160]u8 = undefined; + const value = formatInvalidTokenChallenge(&buf, scheme, description) catch return; + addResponseHeader("www-authenticate", value) catch return; + if (scheme == .dpop) dpop.attachNonce(allocator) catch {}; +} + +fn formatInvalidTokenChallenge(buf: []u8, scheme: TokenScheme, description: []const u8) ![]const u8 { + return std.fmt.bufPrint( + buf, + "{s} error=\"invalid_token\", error_description=\"{s}\"", + .{ scheme.name(), description }, + ); +} + fn scopeAllows(actual: []const u8, required: []const u8) bool { if (std.mem.eql(u8, actual, required)) return true; if (!std.mem.eql(u8, required, "com.atproto.access")) return false; @@ -393,3 +427,15 @@ test "decodes query params" { var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("did:plc:service", queryParam("/xrpc/foo?aud=did%3Aplc%3Aservice", "aud", &buf).?); } + +test "formats OAuth invalid token challenges" { + var buf: [160]u8 = undefined; + try std.testing.expectEqualStrings( + "Bearer error=\"invalid_token\", error_description=\"Token has expired\"", + try formatInvalidTokenChallenge(&buf, .bearer, "Token has expired"), + ); + try std.testing.expectEqualStrings( + "DPoP error=\"invalid_token\", error_description=\"Token has been revoked\"", + try formatInvalidTokenChallenge(&buf, .dpop, "Token has been revoked"), + ); +} diff --git a/tools/smoke.sh b/tools/smoke.sh index f916f54..8ef7e40 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -69,6 +69,21 @@ printf '%s' "$session" | grep -q '"didDoc":' ! printf '%s' "$session" | grep -q '"status":' printf '%s' "$session" | grep -q '"serviceEndpoint":"http://127.0.0.1:' +invalid_auth_headers="${TMPDIR:-/tmp}/zds-smoke-invalid-auth.headers" +invalid_auth_body="${TMPDIR:-/tmp}/zds-smoke-invalid-auth.json" +invalid_auth_status=$(curl -sS -D "$invalid_auth_headers" -o "$invalid_auth_body" -w '%{http_code}' \ + -H 'authorization: Bearer invalid-token' \ + "$base/xrpc/com.atproto.server.getSession") +test "$invalid_auth_status" = "401" +grep -qi '^www-authenticate: Bearer error="invalid_token", error_description="Token is invalid"' "$invalid_auth_headers" + +invalid_dpop_status=$(curl -sS -D "$invalid_auth_headers" -o "$invalid_auth_body" -w '%{http_code}' \ + -H 'authorization: DPoP invalid-token' \ + "$base/xrpc/com.atproto.server.getSession") +test "$invalid_dpop_status" = "401" +grep -qi '^www-authenticate: DPoP error="invalid_token", error_description="Token is invalid"' "$invalid_auth_headers" +grep -qi '^dpop-nonce:' "$invalid_auth_headers" + describe=$(curl -fsS "$base/xrpc/com.atproto.server.describeServer") printf '%s' "$describe" | grep -q '"inviteCodeRequired":true' missing_invite_status=$(curl -sS -o /tmp/zds-missing-invite.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.server.createAccount" \