| @@ -0,0 +1,556 @@ |
| 1 | +//! The `mobile` namespace's one Sign in with Apple action: `signInWithApple`. |
| 2 | +//! |
| 3 | +//! Separate from `bridge_mobile_auth.zig`, which owns `authenticate` — that is |
| 4 | +//! LocalAuthentication and a biometric prompt, this is AuthenticationServices |
| 5 | +//! and an account. They share a word and nothing else. |
| 6 | +//! |
| 7 | +//! ## Why this one is reachable and `scanQRCode` is not |
| 8 | +//! |
| 9 | +//! Both were unmigrated with no recorded reason. AuthenticationServices has |
| 10 | +//! Objective-C headers — `ASAuthorizationController.h:38` declares |
| 11 | +//! `presentationAnchorForAuthorizationController:` — so every class and |
| 12 | +//! selector here is reachable from the runtime. VisionKit's `DataScanner` is |
| 13 | +//! not: its ObjC headers carry DocumentCamera and nothing else, and its |
| 14 | +//! delegate takes a Swift enum with associated values. That one is recorded in |
| 15 | +//! the deferral table instead. |
| 16 | +//! |
| 17 | +//! ## The delegate returns an object, which is new here |
| 18 | +//! |
| 19 | +//! Every other delegate in this migration is `void`. This one must answer |
| 20 | +//! `presentationAnchorForAuthorizationController:` with the `UIWindow` to |
| 21 | +//! present over, so `ios_delegate.enc.object_one_object` (`"@@:@"`) was added |
| 22 | +//! for it. The leading character of an encoding is the return type; declaring |
| 23 | +//! it `v@:@` would have the runtime read a window pointer out of a function |
| 24 | +//! that returns nothing. |
| 25 | +//! |
| 26 | +//! ## The reply |
| 27 | +//! |
| 28 | +//! `{"userId":…}` always, plus `email`, `name` and `identityToken` only when |
| 29 | +//! present — the spec builds the dictionary conditionally and this reproduces |
| 30 | +//! that, because a page distinguishing "Apple withheld the email" from |
| 31 | +//! `"email": ""` is reading a real difference. `name` is `givenName` and |
| 32 | +//! `familyName` joined by a space, omitting whichever is absent, and omitted |
| 33 | +//! entirely when both are. |
| 34 | +//! |
| 35 | +//! Apple sends `email` and `fullName` **only on the first authorization** for |
| 36 | +//! an app. Every later sign-in omits them, which is the API's design and not a |
| 37 | +//! failure — another reason the fields are omitted rather than emptied. |
| 38 | + |
| 39 | +const std = ; |
| 40 | +const builtin = ; |
| 41 | +const capabilities = ; |
| 42 | +const bridge_error = ; |
| 43 | +const objc_runtime = ; |
| 44 | +const ios_async = ; |
| 45 | +const ios_delegate = ; |
| 46 | +const compat_mutex = ; |
| 47 | + |
| 48 | +const objc = objc_runtime.objc; |
| 49 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 50 | +const BridgeError = bridge_error.BridgeError; |
| 51 | + |
| 52 | +/// `objc.id` spelled locally — see the notifcancel/securestore precedent. |
| 53 | +const Id = ?*anyopaque; |
| 54 | + |
| 55 | +pub const A = struct { |
| 56 | + pub const sign_in_with_apple = "signInWithApple"; |
| 57 | +}; |
| 58 | + |
| 59 | +/// `.result`: the spec resolves an object and the page's promise is the |
| 60 | +/// untimed legacy kind, so `.none` would strand the caller. |
| 61 | +pub const capability_actions = [_]capabilities.ActionDecl{ |
| 62 | + .{ .name = A.sign_in_with_apple, .reply = .result }, |
| 63 | +}; |
| 64 | + |
| 65 | +const Route = enum { sign_in }; |
| 66 | + |
| 67 | +fn routeFor(action: []const u8) ?Route { |
| 68 | + if (std.mem.eql(u8, action, A.sign_in_with_apple)) return .sign_in; |
| 69 | + return null; |
| 70 | +} |
| 71 | + |
| 72 | +/// `ASAuthorizationScopeFullName` and `ASAuthorizationScopeEmail`. |
| 73 | +/// |
| 74 | +/// These are `NSString *` constants, not an option set, so they are read with |
| 75 | +/// `dlsym` rather than spelled — the route `bridge_mobile_imagepicker.zig` |
| 76 | +/// documents for framework string constants. A wrong literal here would be a |
| 77 | +/// scope the request silently does not ask for. |
| 78 | +const scope_full_name = "ASAuthorizationScopeFullName"; |
| 79 | +const scope_email = "ASAuthorizationScopeEmail"; |
| 80 | + |
| 81 | +pub const AppleAuthBridge = struct { |
| 82 | + allocator: std.mem.Allocator, |
| 83 | + |
| 84 | + const Self = @This(); |
| 85 | + |
| 86 | + pub fn init(allocator: std.mem.Allocator) Self { |
| 87 | + return .{ .allocator = allocator }; |
| 88 | + } |
| 89 | + |
| 90 | + pub fn deinit(_: *Self) void {} |
| 91 | + |
| 92 | + pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { |
| 93 | + _ = data; |
| 94 | + const route = routeFor(action) orelse return BridgeError.UnknownAction; |
| 95 | + switch (route) { |
| 96 | + .sign_in => try self.signInWithApple(), |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + /// Build the request and perform it. |
| 101 | + /// |
| 102 | + /// Every fallible step runs before `ios_async.acquire`, so no error path |
| 103 | + /// sits between leasing a slot and handing the controller its delegate. |
| 104 | + fn signInWithApple(self: *Self) !void { |
| 105 | + _ = self; |
| 106 | + if (!is_darwin) return error.UnsupportedPlatform; |
| 107 | + |
| 108 | + const ProviderClass = objc.objc_getClass("ASAuthorizationAppleIDProvider") orelse |
| 109 | + return BridgeError.PlatformNotSupported; |
| 110 | + const ControllerClass = objc.objc_getClass("ASAuthorizationController") orelse |
| 111 | + return BridgeError.PlatformNotSupported; |
| 112 | + |
| 113 | + const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound; |
| 114 | + const sel_init = objc.sel_registerName("init") orelse return error.SelectorNotFound; |
| 115 | + const sel_create = objc.sel_registerName("createRequest") orelse return error.SelectorNotFound; |
| 116 | + const sel_scopes = objc.sel_registerName("setRequestedScopes:") orelse return error.SelectorNotFound; |
| 117 | + const sel_init_reqs = objc.sel_registerName("initWithAuthorizationRequests:") orelse |
| 118 | + return error.SelectorNotFound; |
| 119 | + const sel_set_delegate = objc.sel_registerName("setDelegate:") orelse return error.SelectorNotFound; |
| 120 | + const sel_set_ctx = objc.sel_registerName("setPresentationContextProvider:") orelse |
| 121 | + return error.SelectorNotFound; |
| 122 | + const sel_perform = objc.sel_registerName("performRequests") orelse return error.SelectorNotFound; |
| 123 | + |
| 124 | + const handler = try delegateInstance(); |
| 125 | + |
| 126 | + const provider = objc.msgSendId(objc.msgSendId(ProviderClass, sel_alloc), sel_init); |
| 127 | + if (provider == null) return BridgeError.NativeCallFailed; |
| 128 | + const request = objc.msgSendId(provider, sel_create); |
| 129 | + if (request == null) return BridgeError.NativeCallFailed; |
| 130 | + |
| 131 | + if (try requestedScopes()) |scopes| objc.msgSendVoid1(request, sel_scopes, scopes); |
| 132 | + |
| 133 | + const requests = try arrayOf(request); |
| 134 | + const controller = objc.msgSendId1(objc.msgSendId(ControllerClass, sel_alloc), sel_init_reqs, requests); |
| 135 | + if (controller == null) return BridgeError.NativeCallFailed; |
| 136 | + |
| 137 | + const ticket = ios_async.acquire(A.sign_in_with_apple) orelse return poolFull(); |
| 138 | + errdefer ios_async.abandon(ticket); |
| 139 | + |
| 140 | + objc.msgSendVoid1(controller, sel_set_delegate, handler); |
| 141 | + objc.msgSendVoid1(controller, sel_set_ctx, handler); |
| 142 | + |
| 143 | + // Published before `performRequests`: the delegate can fire before it |
| 144 | + // returns, and a callback at an empty slot has no ticket to answer. |
| 145 | + publishPending(ticket); |
| 146 | + objc.msgSend(controller, sel_perform); |
| 147 | + } |
| 148 | +}; |
| 149 | + |
| 150 | +fn poolFull() BridgeError { |
| 151 | + std.log.warn("signInWithApple refused: all {d} async slots in flight", .{ios_async.max_in_flight}); |
| 152 | + return BridgeError.InvalidParameter; |
| 153 | +} |
| 154 | + |
| 155 | +/// `@[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]`, or null when |
| 156 | +/// neither constant is in the process — in which case the request simply asks |
| 157 | +/// for no scopes, which is what an unscoped `createRequest` already does. |
| 158 | +fn requestedScopes() !?Id { |
| 159 | + const full = dlsymString(scope_full_name); |
| 160 | + const email = dlsymString(scope_email); |
| 161 | + if (full == null and email == null) return null; |
| 162 | + |
| 163 | + const NSArray = objc.objc_getClass("NSArray") orelse return null; |
| 164 | + const sel = objc.sel_registerName("arrayWithObjects:count:") orelse return null; |
| 165 | + var items: [2]Id = undefined; |
| 166 | + var n: usize = 0; |
| 167 | + if (full) |f| { |
| 168 | + items[n] = f; |
| 169 | + n += 1; |
| 170 | + } |
| 171 | + if (email) |e| { |
| 172 | + items[n] = e; |
| 173 | + n += 1; |
| 174 | + } |
| 175 | + const Fn = *const fn (Id, objc.SEL, [*]const Id, usize) callconv(.c) Id; |
| 176 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 177 | + return func(NSArray, sel, &items, n); |
| 178 | +} |
| 179 | + |
| 180 | +fn arrayOf(object: Id) !Id { |
| 181 | + const NSArray = objc.objc_getClass("NSArray") orelse return BridgeError.NativeCallFailed; |
| 182 | + const sel = objc.sel_registerName("arrayWithObject:") orelse return error.SelectorNotFound; |
| 183 | + const result = objc.msgSendId1(NSArray, sel, object); |
| 184 | + if (result == null) return BridgeError.NativeCallFailed; |
| 185 | + return result; |
| 186 | +} |
| 187 | + |
| 188 | +extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; |
| 189 | +const rtld_default: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); |
| 190 | + |
| 191 | +/// Read an `NSString *` framework constant. `dlsym` gives the address of the |
| 192 | +/// variable, so the pointer has to be dereferenced to get the object. |
| 193 | +fn dlsymString(name: [:0]const u8) ?Id { |
| 194 | + if (!is_darwin) return null; |
| 195 | + const addr = dlsym(rtld_default, name.ptr) orelse return null; |
| 196 | + const slot: *const Id = @ptrCast(@alignCast(addr)); |
| 197 | + return slot.*; |
| 198 | +} |
| 199 | +// ============================================================================= |
| 200 | +// The pending sign-in, and the delegate that answers it. |
| 201 | +// ============================================================================= |
| 202 | + |
| 203 | +var pending: ?ios_async.Ticket = null; |
| 204 | +var pending_mutex: compat_mutex.Mutex = .{}; |
| 205 | + |
| 206 | +fn publishPending(ticket: ios_async.Ticket) void { |
| 207 | + pending_mutex.lock(); |
| 208 | + defer pending_mutex.unlock(); |
| 209 | + pending = ticket; |
| 210 | +} |
| 211 | + |
| 212 | +/// Read and clear, so a second callback answers nobody rather than twice. |
| 213 | +fn takePending() ?ios_async.Ticket { |
| 214 | + pending_mutex.lock(); |
| 215 | + defer pending_mutex.unlock(); |
| 216 | + const t = pending; |
| 217 | + pending = null; |
| 218 | + return t; |
| 219 | +} |
| 220 | + |
| 221 | +var delegate: ?Id = null; |
| 222 | +const delegate_class_name = "CraftAppleAuthDelegate"; |
| 223 | + |
| 224 | +/// One instance, retained for the life of the process. |
| 225 | +/// |
| 226 | +/// `ASAuthorizationController` holds both its `delegate` and its |
| 227 | +/// `presentationContextProvider` weakly, so an instance built per call would |
| 228 | +/// be gone before the sheet came up. |
| 229 | +fn delegateInstance() !Id { |
| 230 | + if (delegate) |existing| return existing; |
| 231 | + |
| 232 | + const class = ios_delegate.defineClass(delegate_class_name, "NSObject", &.{ |
| 233 | + .{ |
| 234 | + .selector = "authorizationController:didCompleteWithAuthorization:", |
| 235 | + .imp = @ptrCast(&craftAppleDidComplete), |
| 236 | + .types = ios_delegate.enc.void_two_objects, |
| 237 | + }, |
| 238 | + .{ |
| 239 | + .selector = "authorizationController:didCompleteWithError:", |
| 240 | + .imp = @ptrCast(&craftAppleDidFail), |
| 241 | + .types = ios_delegate.enc.void_two_objects, |
| 242 | + }, |
| 243 | + .{ |
| 244 | + .selector = "presentationAnchorForAuthorizationController:", |
| 245 | + .imp = @ptrCast(&craftApplePresentationAnchor), |
| 246 | + .types = ios_delegate.enc.object_one_object, |
| 247 | + }, |
| 248 | + }) catch |err| { |
| 249 | + std.log.warn("signInWithApple: could not build the delegate class: {}", .{err}); |
| 250 | + return BridgeError.NativeCallFailed; |
| 251 | + }; |
| 252 | + |
| 253 | + delegate = ios_delegate.instantiate(class) catch |err| { |
| 254 | + std.log.warn("signInWithApple: could not instantiate the delegate: {}", .{err}); |
| 255 | + return BridgeError.NativeCallFailed; |
| 256 | + }; |
| 257 | + return delegate.?; |
| 258 | +} |
| 259 | + |
| 260 | +fn craftAppleDidComplete(_: Id, _: objc.SEL, _: Id, authorization: Id) callconv(.c) void { |
| 261 | + if (!is_darwin) return; |
| 262 | + const ticket = takePending() orelse { |
| 263 | + std.log.warn("signInWithApple: an authorization arrived with no call recorded; ignored", .{}); |
| 264 | + return; |
| 265 | + }; |
| 266 | + |
| 267 | + const allocator = std.heap.c_allocator; |
| 268 | + const json = shapeCredential(allocator, authorization) catch |err| { |
| 269 | + std.log.err("signInWithApple could not shape its reply ({}); rejecting", .{err}); |
| 270 | + ios_async.deliverError(ticket); |
| 271 | + return; |
| 272 | + }; |
| 273 | + defer allocator.free(json); |
| 274 | + ios_async.deliverJson(ticket, json); |
| 275 | +} |
| 276 | + |
| 277 | +fn craftAppleDidFail(_: Id, _: objc.SEL, _: Id, err: Id) callconv(.c) void { |
| 278 | + if (!is_darwin) return; |
| 279 | + const ticket = takePending() orelse return; |
| 280 | + |
| 281 | + // 1001 is `ASAuthorizationError.canceled`. The spec rejects every failure |
| 282 | + // with the same localized string, so a page cannot tell a cancel from a |
| 283 | + // real error; a typed code can, and cancel is not a failure to report. |
| 284 | + const code = errorCode(err); |
| 285 | + if (code == as_authorization_error_canceled) { |
| 286 | + ios_async.deliverErrorCode(ticket, BridgeError.Cancelled); |
| 287 | + return; |
| 288 | + } |
| 289 | + std.log.warn("signInWithApple: the authorization failed with code {d}", .{code}); |
| 290 | + ios_async.deliverErrorCode(ticket, BridgeError.NativeCallFailed); |
| 291 | +} |
| 292 | + |
| 293 | +/// `ASAuthorizationError.canceled`. `ASAuthorizationError.h` — the user |
| 294 | +/// dismissed the sheet. |
| 295 | +const as_authorization_error_canceled: i64 = 1001; |
| 296 | + |
| 297 | +/// The window the sheet is presented over. |
| 298 | +/// |
| 299 | +/// The spec walks `connectedScenes.first` → `windows.first` and returns a bare |
| 300 | +/// `UIWindow()` when that misses. A fresh detached window is not presentable, |
| 301 | +/// so that fallback shows nothing; returning nil is the same outcome and does |
| 302 | +/// not allocate a window to throw away. |
| 303 | +fn craftApplePresentationAnchor(_: Id, _: objc.SEL, _: Id) callconv(.c) Id { |
| 304 | + if (!is_darwin) return null; |
| 305 | + return keyWindow(); |
| 306 | +} |
| 307 | + |
| 308 | +fn keyWindow() Id { |
| 309 | + const UIApplication = objc.objc_getClass("UIApplication") orelse return null; |
| 310 | + const sel_shared = objc.sel_registerName("sharedApplication") orelse return null; |
| 311 | + const app = objc.msgSendId(UIApplication, sel_shared); |
| 312 | + if (app == null) return null; |
| 313 | + |
| 314 | + const sel_scenes = objc.sel_registerName("connectedScenes") orelse return null; |
| 315 | + const scenes = objc.msgSendId(app, sel_scenes); |
| 316 | + if (scenes == null) return null; |
| 317 | + |
| 318 | + const sel_all = objc.sel_registerName("allObjects") orelse return null; |
| 319 | + const list = objc.msgSendId(scenes, sel_all); |
| 320 | + if (list == null) return null; |
| 321 | + |
| 322 | + const sel_count = objc.sel_registerName("count") orelse return null; |
| 323 | + const CountFn = *const fn (Id, objc.SEL) callconv(.c) usize; |
| 324 | + const count_fn: CountFn = @ptrCast(&objc.objc_msgSend); |
| 325 | + if (count_fn(list, sel_count) == 0) return null; |
| 326 | + |
| 327 | + const sel_at = objc.sel_registerName("objectAtIndex:") orelse return null; |
| 328 | + const AtFn = *const fn (Id, objc.SEL, usize) callconv(.c) Id; |
| 329 | + const at_fn: AtFn = @ptrCast(&objc.objc_msgSend); |
| 330 | + const scene = at_fn(list, sel_at, 0); |
| 331 | + |
| 332 | + const sel_windows = objc.sel_registerName("windows") orelse return null; |
| 333 | + const windows = objc.msgSendId(scene, sel_windows); |
| 334 | + if (windows == null) return null; |
| 335 | + if (count_fn(windows, sel_count) == 0) return null; |
| 336 | + return at_fn(windows, sel_at, 0); |
| 337 | +} |
| 338 | + |
| 339 | +fn errorCode(err: Id) i64 { |
| 340 | + const e = err orelse return 0; |
| 341 | + const sel = objc.sel_registerName("code") orelse return 0; |
| 342 | + const Fn = *const fn (Id, objc.SEL) callconv(.c) isize; |
| 343 | + const func: Fn = @ptrCast(&objc.objc_msgSend); |
| 344 | + return @intCast(func(e, sel)); |
| 345 | +} |
| 346 | + |
| 347 | +// ============================================================================= |
| 348 | +// Shaping the reply. Split out so the half that decides what the page sees is |
| 349 | +// testable on a host with no AuthenticationServices in the process. |
| 350 | +// ============================================================================= |
| 351 | + |
| 352 | +/// The credential as the page receives it. Absent fields are absent, not empty. |
| 353 | +const Credential = struct { |
| 354 | + user_id: []const u8, |
| 355 | + email: ?[]const u8 = null, |
| 356 | + given_name: ?[]const u8 = null, |
| 357 | + family_name: ?[]const u8 = null, |
| 358 | + identity_token: ?[]const u8 = null, |
| 359 | +}; |
| 360 | + |
| 361 | +fn appendJsonString(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), s: []const u8) !void { |
| 362 | + try out.append(allocator, '"'); |
| 363 | + try bridge_error.appendJsonEscaped(allocator, out, s); |
| 364 | + try out.append(allocator, '"'); |
| 365 | +} |
| 366 | + |
| 367 | +/// `{"userId":…}` plus whatever else Apple sent. |
| 368 | +/// |
| 369 | +/// `name` is the spec's join: given, then family, separated by a space, with |
| 370 | +/// whichever is absent skipped, and the key omitted when the result is empty. |
| 371 | +fn appendCredential(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), c: Credential) !void { |
| 372 | + try out.appendSlice(allocator, "{\"userId\":"); |
| 373 | + try appendJsonString(allocator, out, c.user_id); |
| 374 | + |
| 375 | + if (c.email) |e| { |
| 376 | + try out.appendSlice(allocator, ",\"email\":"); |
| 377 | + try appendJsonString(allocator, out, e); |
| 378 | + } |
| 379 | + |
| 380 | + const given = c.given_name orelse ""; |
| 381 | + const family = c.family_name orelse ""; |
| 382 | + if (given.len != 0 or family.len != 0) { |
| 383 | + try out.appendSlice(allocator, ",\"name\":\""); |
| 384 | + try bridge_error.appendJsonEscaped(allocator, out, given); |
| 385 | + if (given.len != 0 and family.len != 0) try out.append(allocator, ' '); |
| 386 | + try bridge_error.appendJsonEscaped(allocator, out, family); |
| 387 | + try out.append(allocator, '"'); |
| 388 | + } |
| 389 | + |
| 390 | + if (c.identity_token) |t| { |
| 391 | + try out.appendSlice(allocator, ",\"identityToken\":"); |
| 392 | + try appendJsonString(allocator, out, t); |
| 393 | + } |
| 394 | + try out.append(allocator, '}'); |
| 395 | +} |
| 396 | + |
| 397 | +fn shapeCredential(allocator: std.mem.Allocator, authorization: Id) ![]u8 { |
| 398 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 399 | + errdefer out.deinit(allocator); |
| 400 | + |
| 401 | + if (!is_darwin or authorization == null) return BridgeError.NativeCallFailed; |
| 402 | + |
| 403 | + const sel_credential = objc.sel_registerName("credential") orelse return error.SelectorNotFound; |
| 404 | + const credential = objc.msgSendId(authorization, sel_credential); |
| 405 | + if (credential == null) return BridgeError.NativeCallFailed; |
| 406 | + |
| 407 | + // `as? ASAuthorizationAppleIDCredential` in the spec: a password |
| 408 | + // credential reaches the same delegate and has no `user`. |
| 409 | + const AppleIDCredential = objc.objc_getClass("ASAuthorizationAppleIDCredential") orelse |
| 410 | + return BridgeError.NativeCallFailed; |
| 411 | + const sel_is_kind = objc.sel_registerName("isKindOfClass:") orelse return error.SelectorNotFound; |
| 412 | + const KindFn = *const fn (Id, objc.SEL, Id) callconv(.c) bool; |
| 413 | + const kind_fn: KindFn = @ptrCast(&objc.objc_msgSend); |
| 414 | + if (!kind_fn(credential, sel_is_kind, AppleIDCredential)) return BridgeError.NativeCallFailed; |
| 415 | + |
| 416 | + const user_id = nsStringValue(credential, "user") orelse return BridgeError.NativeCallFailed; |
| 417 | + const email = nsStringValue(credential, "email"); |
| 418 | + |
| 419 | + const sel_full_name = objc.sel_registerName("fullName") orelse return error.SelectorNotFound; |
| 420 | + const full_name = objc.msgSendId(credential, sel_full_name); |
| 421 | + const given = if (full_name != null) nsStringValue(full_name, "givenName") else null; |
| 422 | + const family = if (full_name != null) nsStringValue(full_name, "familyName") else null; |
| 423 | + |
| 424 | + const sel_token = objc.sel_registerName("identityToken") orelse return error.SelectorNotFound; |
| 425 | + const token_data = objc.msgSendId(credential, sel_token); |
| 426 | + const token = dataUtf8(token_data); |
| 427 | + |
| 428 | + try appendCredential(allocator, &out, .{ |
| 429 | + .user_id = user_id, |
| 430 | + .email = email, |
| 431 | + .given_name = given, |
| 432 | + .family_name = family, |
| 433 | + .identity_token = token, |
| 434 | + }); |
| 435 | + return out.toOwnedSlice(allocator); |
| 436 | +} |
| 437 | + |
| 438 | +fn nsStringValue(target: Id, comptime selector: [:0]const u8) ?[]const u8 { |
| 439 | + if (!is_darwin) return null; |
| 440 | + const t = target orelse return null; |
| 441 | + const sel = objc.sel_registerName(selector) orelse return null; |
| 442 | + const ns = objc.msgSendId(t, sel); |
| 443 | + if (ns == null) return null; |
| 444 | + const utf8 = objc.getNSStringUTF8(ns) orelse return null; |
| 445 | + return std.mem.span(utf8); |
| 446 | +} |
| 447 | + |
| 448 | +/// The identity token is an `NSData` of JWT bytes; the spec decodes it as |
| 449 | +/// UTF-8 and omits the key when that fails. |
| 450 | +fn dataUtf8(data: Id) ?[]const u8 { |
| 451 | + if (!is_darwin) return null; |
| 452 | + const d = data orelse return null; |
| 453 | + const sel_bytes = objc.sel_registerName("bytes") orelse return null; |
| 454 | + const sel_length = objc.sel_registerName("length") orelse return null; |
| 455 | + const BytesFn = *const fn (Id, objc.SEL) callconv(.c) ?[*]const u8; |
| 456 | + const bytes_fn: BytesFn = @ptrCast(&objc.objc_msgSend); |
| 457 | + const LenFn = *const fn (Id, objc.SEL) callconv(.c) usize; |
| 458 | + const len_fn: LenFn = @ptrCast(&objc.objc_msgSend); |
| 459 | + |
| 460 | + const len = len_fn(d, sel_length); |
| 461 | + if (len == 0) return null; |
| 462 | + const ptr = bytes_fn(d, sel_bytes) orelse return null; |
| 463 | + const slice = ptr[0..len]; |
| 464 | + return if (std.unicode.utf8ValidateSlice(slice)) slice else null; |
| 465 | +} |
| 466 | + |
| 467 | +// ============================================================================= |
| 468 | +// Tests — host-only. AuthenticationServices is not in this process, so what is |
| 469 | +// pinned is everything that decides what the page sees. |
| 470 | +// ============================================================================= |
| 471 | + |
| 472 | +const testing = std.testing; |
| 473 | + |
| 474 | +test "the declared action is the one the handler serves" { |
| 475 | + try testing.expectEqual(@as(usize, 1), capability_actions.len); |
| 476 | + try testing.expectEqualStrings(A.sign_in_with_apple, capability_actions[0].name); |
| 477 | + try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply); |
| 478 | + try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status); |
| 479 | + try testing.expect(routeFor(A.sign_in_with_apple) == .sign_in); |
| 480 | + try testing.expect(routeFor("authenticate") == null); |
| 481 | +} |
| 482 | + |
| 483 | +test "an action this namespace does not serve is reported, not ignored" { |
| 484 | + var bridge = AppleAuthBridge.init(testing.allocator); |
| 485 | + defer bridge.deinit(); |
| 486 | + // `authenticate` belongs to bridge_mobile_auth.zig; claiming it here would |
| 487 | + // make dispatch order decide which implementation a page gets. |
| 488 | + try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("authenticate", "{}")); |
| 489 | +} |
| 490 | + |
| 491 | +test "a first authorization carries everything; a later one carries only the id" { |
| 492 | + const allocator = testing.allocator; |
| 493 | + |
| 494 | + var first: std.ArrayListUnmanaged(u8) = .empty; |
| 495 | + defer first.deinit(allocator); |
| 496 | + try appendCredential(allocator, &first, .{ |
| 497 | + .user_id = "001234.abc", |
| 498 | + .email = "a@b.c", |
| 499 | + .given_name = "Ada", |
| 500 | + .family_name = "Lovelace", |
| 501 | + .identity_token = "ey.J.token", |
| 502 | + }); |
| 503 | + try testing.expectEqualStrings( |
| 504 | + "{\"userId\":\"001234.abc\",\"email\":\"a@b.c\",\"name\":\"Ada Lovelace\",\"identityToken\":\"ey.J.token\"}", |
| 505 | + first.items, |
| 506 | + ); |
| 507 | + |
| 508 | + // Apple sends email and fullName only on the *first* authorization for an |
| 509 | + // app; every later one omits them. Absent keys, not empty strings — a page |
| 510 | + // telling "Apple withheld it" from `""` is reading a real difference. |
| 511 | + var later: std.ArrayListUnmanaged(u8) = .empty; |
| 512 | + defer later.deinit(allocator); |
| 513 | + try appendCredential(allocator, &later, .{ .user_id = "001234.abc" }); |
| 514 | + try testing.expectEqualStrings("{\"userId\":\"001234.abc\"}", later.items); |
| 515 | +} |
| 516 | + |
| 517 | +test "a half-present name joins without a stray space" { |
| 518 | + const allocator = testing.allocator; |
| 519 | + for ([_]struct { g: ?[]const u8, f: ?[]const u8, want: []const u8 }{ |
| 520 | + .{ .g = "Ada", .f = null, .want = "{\"userId\":\"u\",\"name\":\"Ada\"}" }, |
| 521 | + .{ .g = null, .f = "Lovelace", .want = "{\"userId\":\"u\",\"name\":\"Lovelace\"}" }, |
| 522 | + .{ .g = null, .f = null, .want = "{\"userId\":\"u\"}" }, |
| 523 | + }) |c| { |
| 524 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 525 | + defer out.deinit(allocator); |
| 526 | + try appendCredential(allocator, &out, .{ .user_id = "u", .given_name = c.g, .family_name = c.f }); |
| 527 | + try testing.expectEqualStrings(c.want, out.items); |
| 528 | + } |
| 529 | +} |
| 530 | + |
| 531 | +test "a name carrying a quote stays valid JSON" { |
| 532 | + const allocator = testing.allocator; |
| 533 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 534 | + defer out.deinit(allocator); |
| 535 | + try appendCredential(allocator, &out, .{ .user_id = "u", .given_name = "A\"B", .family_name = "C\\D" }); |
| 536 | + |
| 537 | + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, out.items, .{}); |
| 538 | + defer parsed.deinit(); |
| 539 | + try testing.expectEqualStrings("A\"B C\\D", parsed.value.object.get("name").?.string); |
| 540 | +} |
| 541 | + |
| 542 | +test "the cancel code is the framework's" { |
| 543 | + // `ASAuthorizationError.canceled` = 1001. The spec rejects every failure |
| 544 | + // with the same localized string, so a page cannot tell a dismissal from a |
| 545 | + // real error; this answers CANCELLED for that one. |
| 546 | + try testing.expectEqual(@as(i64, 1001), as_authorization_error_canceled); |
| 547 | +} |
| 548 | + |
| 549 | +test "the presentation anchor is object-returning, not void" { |
| 550 | + // The encoding's first character is the return type. Declaring this |
| 551 | + // delegate method `v@:@` would have the runtime read a window pointer out |
| 552 | + // of a function that returns nothing. |
| 553 | + try testing.expectEqualStrings("@@:@", ios_delegate.enc.object_one_object); |
| 554 | + try testing.expect(ios_delegate.enc.object_one_object[0] == '@'); |
| 555 | + try testing.expect(ios_delegate.enc.void_one_object[0] == 'v'); |
| 556 | +} |