| ... | ... | @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; |
| 10 | 10 | const MAX_JOB_AGE_MS = 10 * 60 * 1_000; |
| 11 | 11 | export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; |
| 12 | 12 | |
| 13 | | const ApnsDeliveryKind = Schema.Literals([ |
| 13 | const ApnsDeliveryKindSchema = Schema.Literals([ |
| 14 | 14 | "live_activity_start", |
| 15 | 15 | "live_activity_update", |
| 16 | 16 | "live_activity_end", |
| 17 | 17 | "push_notification", |
| 18 | 18 | ]); |
| 19 | const LiveActivityStartOrUpdateKindSchema = Schema.Literals([ |
| 20 | "live_activity_start", |
| 21 | "live_activity_update", |
| 22 | ]); |
| 23 | const LiveActivityKindSchema = Schema.Literals([ |
| 24 | "live_activity_start", |
| 25 | "live_activity_update", |
| 26 | "live_activity_end", |
| 27 | ]); |
| 28 | |
| 29 | const ApnsDeliveryJobContext = { |
| 30 | jobId: Schema.String, |
| 31 | userId: Schema.String, |
| 32 | deviceId: Schema.String, |
| 33 | }; |
| 19 | 34 | |
| 20 | 35 | export const ApnsNotificationPayload = Schema.Struct({ |
| 21 | 36 | title: Schema.String, |
| ... | ... | @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; |
| 29 | 44 | export const ApnsDeliveryJobPayload = Schema.Struct({ |
| 30 | 45 | version: Schema.Literal(1), |
| 31 | 46 | jobId: Schema.String, |
| 32 | | kind: ApnsDeliveryKind, |
| 47 | kind: ApnsDeliveryKindSchema, |
| 33 | 48 | target: Schema.Struct({ |
| 34 | 49 | userId: Schema.String, |
| 35 | 50 | deviceId: Schema.String, |
| ... | ... | @@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; |
| 51 | 66 | |
| 52 | 67 | export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobQueuePayloadInvalid>()( |
| 53 | 68 | "ApnsDeliveryJobQueuePayloadInvalid", |
| 54 | | {}, |
| 69 | { |
| 70 | receivedType: Schema.String, |
| 71 | cause: Schema.Defect(), |
| 72 | }, |
| 55 | 73 | ) { |
| 56 | 74 | override get message(): string { |
| 57 | | return "Invalid APNs delivery queue job."; |
| 75 | return `Invalid APNs delivery queue job with ${this.receivedType} payload.`; |
| 58 | 76 | } |
| 59 | 77 | } |
| 60 | 78 | |
| 61 | 79 | export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityAggregateMissing>()( |
| 62 | 80 | "ApnsDeliveryJobLiveActivityAggregateMissing", |
| 63 | | {}, |
| 81 | { |
| 82 | ...ApnsDeliveryJobContext, |
| 83 | kind: LiveActivityStartOrUpdateKindSchema, |
| 84 | }, |
| 64 | 85 | ) { |
| 65 | 86 | override get message(): string { |
| 66 | | return "Live Activity start/update jobs require an aggregate."; |
| 87 | return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`; |
| 67 | 88 | } |
| 68 | 89 | } |
| 69 | 90 | |
| 70 | 91 | export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityNotificationUnexpected>()( |
| 71 | 92 | "ApnsDeliveryJobLiveActivityNotificationUnexpected", |
| 72 | | {}, |
| 93 | { |
| 94 | ...ApnsDeliveryJobContext, |
| 95 | kind: LiveActivityKindSchema, |
| 96 | }, |
| 73 | 97 | ) { |
| 74 | 98 | override get message(): string { |
| 75 | | return "Live Activity jobs must not carry push notification payloads."; |
| 99 | return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`; |
| 76 | 100 | } |
| 77 | 101 | } |
| 78 | 102 | |
| 79 | 103 | export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationMissing>()( |
| 80 | 104 | "ApnsDeliveryJobPushNotificationMissing", |
| 81 | | {}, |
| 105 | ApnsDeliveryJobContext, |
| 82 | 106 | ) { |
| 83 | 107 | override get message(): string { |
| 84 | | return "Push notification jobs require a notification payload."; |
| 108 | return `APNs push notification job ${this.jobId} requires a notification payload.`; |
| 85 | 109 | } |
| 86 | 110 | } |
| 87 | 111 | |
| 88 | 112 | export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationAggregateUnexpected>()( |
| 89 | 113 | "ApnsDeliveryJobPushNotificationAggregateUnexpected", |
| 90 | | {}, |
| 114 | ApnsDeliveryJobContext, |
| 91 | 115 | ) { |
| 92 | 116 | override get message(): string { |
| 93 | | return "Push notification jobs must not carry aggregate state."; |
| 117 | return `APNs push notification job ${this.jobId} must not carry aggregate state.`; |
| 94 | 118 | } |
| 95 | 119 | } |
| 96 | 120 | |
| 97 | 121 | export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobCreatedAtInvalid>()( |
| 98 | 122 | "ApnsDeliveryJobCreatedAtInvalid", |
| 99 | | {}, |
| 123 | { |
| 124 | ...ApnsDeliveryJobContext, |
| 125 | kind: ApnsDeliveryKindSchema, |
| 126 | createdAt: Schema.String, |
| 127 | }, |
| 100 | 128 | ) { |
| 101 | 129 | override get message(): string { |
| 102 | | return "Invalid APNs delivery job creation time."; |
| 130 | return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`; |
| 103 | 131 | } |
| 104 | 132 | } |
| 105 | 133 | |
| 106 | 134 | export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobExpiresAtInvalid>()( |
| 107 | 135 | "ApnsDeliveryJobExpiresAtInvalid", |
| 108 | | {}, |
| 136 | { |
| 137 | ...ApnsDeliveryJobContext, |
| 138 | kind: ApnsDeliveryKindSchema, |
| 139 | expiresAt: Schema.String, |
| 140 | }, |
| 109 | 141 | ) { |
| 110 | 142 | override get message(): string { |
| 111 | | return "Invalid APNs delivery job expiry."; |
| 143 | return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`; |
| 112 | 144 | } |
| 113 | 145 | } |
| 114 | 146 | |
| 115 | 147 | export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowInvalid>()( |
| 116 | 148 | "ApnsDeliveryJobTimeWindowInvalid", |
| 117 | | {}, |
| 149 | { |
| 150 | ...ApnsDeliveryJobContext, |
| 151 | kind: ApnsDeliveryKindSchema, |
| 152 | createdAt: Schema.String, |
| 153 | expiresAt: Schema.String, |
| 154 | }, |
| 118 | 155 | ) { |
| 119 | 156 | override get message(): string { |
| 120 | | return "Invalid APNs delivery job time window."; |
| 157 | return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`; |
| 121 | 158 | } |
| 122 | 159 | } |
| 123 | 160 | |
| 124 | 161 | export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowTooLong>()( |
| 125 | 162 | "ApnsDeliveryJobTimeWindowTooLong", |
| 126 | | {}, |
| 163 | { |
| 164 | ...ApnsDeliveryJobContext, |
| 165 | kind: ApnsDeliveryKindSchema, |
| 166 | createdAt: Schema.String, |
| 167 | expiresAt: Schema.String, |
| 168 | }, |
| 127 | 169 | ) { |
| 128 | 170 | override get message(): string { |
| 129 | | return "APNs delivery job time window is too long."; |
| 171 | return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`; |
| 130 | 172 | } |
| 131 | 173 | } |
| 132 | 174 | |
| 133 | 175 | export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobSignatureInvalid>()( |
| 134 | 176 | "ApnsDeliveryJobSignatureInvalid", |
| 135 | | {}, |
| 177 | { |
| 178 | ...ApnsDeliveryJobContext, |
| 179 | kind: ApnsDeliveryKindSchema, |
| 180 | }, |
| 136 | 181 | ) { |
| 137 | 182 | override get message(): string { |
| 138 | | return "Invalid APNs delivery job signature."; |
| 183 | return `Invalid signature for APNs delivery job ${this.jobId}.`; |
| 139 | 184 | } |
| 140 | 185 | } |
| 141 | 186 | |
| ... | ... | @@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; |
| 156 | 201 | export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()( |
| 157 | 202 | "ApnsDeliveryJobExpired", |
| 158 | 203 | { |
| 204 | ...ApnsDeliveryJobContext, |
| 205 | kind: ApnsDeliveryKindSchema, |
| 159 | 206 | expiresAt: Schema.String, |
| 160 | 207 | }, |
| 161 | 208 | ) { |
| 162 | 209 | override get message(): string { |
| 163 | | return `APNs delivery job expired at ${this.expiresAt}`; |
| 210 | return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`; |
| 164 | 211 | } |
| 165 | 212 | } |
| 166 | 213 | |
| ... | ... | @@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI |
| 208 | 255 | case "live_activity_start": |
| 209 | 256 | case "live_activity_update": |
| 210 | 257 | if (payload.aggregate === null) { |
| 211 | | return new ApnsDeliveryJobLiveActivityAggregateMissing(); |
| 258 | return new ApnsDeliveryJobLiveActivityAggregateMissing({ |
| 259 | jobId: payload.jobId, |
| 260 | kind: payload.kind, |
| 261 | userId: payload.target.userId, |
| 262 | deviceId: payload.target.deviceId, |
| 263 | }); |
| 212 | 264 | } |
| 213 | 265 | if (payload.notification !== null) { |
| 214 | | return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); |
| 266 | return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ |
| 267 | jobId: payload.jobId, |
| 268 | kind: payload.kind, |
| 269 | userId: payload.target.userId, |
| 270 | deviceId: payload.target.deviceId, |
| 271 | }); |
| 215 | 272 | } |
| 216 | 273 | return null; |
| 217 | 274 | case "live_activity_end": |
| 218 | 275 | if (payload.notification !== null) { |
| 219 | | return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); |
| 276 | return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ |
| 277 | jobId: payload.jobId, |
| 278 | kind: payload.kind, |
| 279 | userId: payload.target.userId, |
| 280 | deviceId: payload.target.deviceId, |
| 281 | }); |
| 220 | 282 | } |
| 221 | 283 | return null; |
| 222 | 284 | case "push_notification": |
| 223 | 285 | if (payload.notification === null) { |
| 224 | | return new ApnsDeliveryJobPushNotificationMissing(); |
| 286 | return new ApnsDeliveryJobPushNotificationMissing({ |
| 287 | jobId: payload.jobId, |
| 288 | userId: payload.target.userId, |
| 289 | deviceId: payload.target.deviceId, |
| 290 | }); |
| 225 | 291 | } |
| 226 | 292 | if (payload.aggregate !== null) { |
| 227 | | return new ApnsDeliveryJobPushNotificationAggregateUnexpected(); |
| 293 | return new ApnsDeliveryJobPushNotificationAggregateUnexpected({ |
| 294 | jobId: payload.jobId, |
| 295 | userId: payload.target.userId, |
| 296 | deviceId: payload.target.deviceId, |
| 297 | }); |
| 228 | 298 | } |
| 229 | 299 | return null; |
| 230 | 300 | } |
| ... | ... | @@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { |
| 264 | 334 | readonly job: SignedApnsDeliveryJob; |
| 265 | 335 | readonly nowMs: number; |
| 266 | 336 | }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { |
| 267 | | const invalidPayload = validatePayloadShape(input.job.payload); |
| 337 | const payload = input.job.payload; |
| 338 | const invalidPayload = validatePayloadShape(payload); |
| 268 | 339 | if (invalidPayload !== null) { |
| 269 | 340 | return invalidPayload; |
| 270 | 341 | } |
| 271 | | const createdAt = DateTime.make(input.job.payload.createdAt); |
| 342 | const createdAt = DateTime.make(payload.createdAt); |
| 272 | 343 | if (Option.isNone(createdAt)) { |
| 273 | | return new ApnsDeliveryJobCreatedAtInvalid(); |
| 344 | return new ApnsDeliveryJobCreatedAtInvalid({ |
| 345 | jobId: payload.jobId, |
| 346 | kind: payload.kind, |
| 347 | userId: payload.target.userId, |
| 348 | deviceId: payload.target.deviceId, |
| 349 | createdAt: payload.createdAt, |
| 350 | }); |
| 274 | 351 | } |
| 275 | | const expiresAt = DateTime.make(input.job.payload.expiresAt); |
| 352 | const expiresAt = DateTime.make(payload.expiresAt); |
| 276 | 353 | if (Option.isNone(expiresAt)) { |
| 277 | | return new ApnsDeliveryJobExpiresAtInvalid(); |
| 354 | return new ApnsDeliveryJobExpiresAtInvalid({ |
| 355 | jobId: payload.jobId, |
| 356 | kind: payload.kind, |
| 357 | userId: payload.target.userId, |
| 358 | deviceId: payload.target.deviceId, |
| 359 | expiresAt: payload.expiresAt, |
| 360 | }); |
| 278 | 361 | } |
| 279 | 362 | const createdAtMs = createdAt.value.epochMilliseconds; |
| 280 | 363 | const expiresAtMs = expiresAt.value.epochMilliseconds; |
| 281 | 364 | if (expiresAtMs <= createdAtMs) { |
| 282 | | return new ApnsDeliveryJobTimeWindowInvalid(); |
| 365 | return new ApnsDeliveryJobTimeWindowInvalid({ |
| 366 | jobId: payload.jobId, |
| 367 | kind: payload.kind, |
| 368 | userId: payload.target.userId, |
| 369 | deviceId: payload.target.deviceId, |
| 370 | createdAt: payload.createdAt, |
| 371 | expiresAt: payload.expiresAt, |
| 372 | }); |
| 283 | 373 | } |
| 284 | 374 | if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { |
| 285 | | return new ApnsDeliveryJobTimeWindowTooLong(); |
| 375 | return new ApnsDeliveryJobTimeWindowTooLong({ |
| 376 | jobId: payload.jobId, |
| 377 | kind: payload.kind, |
| 378 | userId: payload.target.userId, |
| 379 | deviceId: payload.target.deviceId, |
| 380 | createdAt: payload.createdAt, |
| 381 | expiresAt: payload.expiresAt, |
| 382 | }); |
| 286 | 383 | } |
| 287 | 384 | if (expiresAtMs <= input.nowMs) { |
| 288 | | return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); |
| 385 | return new ApnsDeliveryJobExpired({ |
| 386 | jobId: payload.jobId, |
| 387 | kind: payload.kind, |
| 388 | userId: payload.target.userId, |
| 389 | deviceId: payload.target.deviceId, |
| 390 | expiresAt: payload.expiresAt, |
| 391 | }); |
| 289 | 392 | } |
| 290 | 393 | const expected = signatureForPayload({ |
| 291 | 394 | secret: input.secret, |
| 292 | | payload: input.job.payload, |
| 395 | payload, |
| 293 | 396 | }); |
| 294 | 397 | if (!timingSafeEqualBase64Url(input.job.signature, expected)) { |
| 295 | | return new ApnsDeliveryJobSignatureInvalid(); |
| 398 | return new ApnsDeliveryJobSignatureInvalid({ |
| 399 | jobId: payload.jobId, |
| 400 | kind: payload.kind, |
| 401 | userId: payload.target.userId, |
| 402 | deviceId: payload.target.deviceId, |
| 403 | }); |
| 296 | 404 | } |
| 297 | | return input.job.payload; |
| 405 | return payload; |
| 298 | 406 | } |