grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add iOS native OAuth client and local CSRF patch

Register grain-native://app OAuth client for iOS and add
grain://oauth/callback redirect URI. Patch PDS CSRF cookie
to drop Secure flag for local ASWebAuthenticationSession dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+72 -3
+1
docker-compose.yml
··· 36 36 - LOG_ENABLED=true 37 37 volumes: 38 38 - pds_data:/pds 39 + - ./patches/pds-csrf-no-secure.js:/app/node_modules/.pnpm/@atproto+oauth-provider@0.15.9/node_modules/@atproto/oauth-provider/dist/router/assets/csrf.js:ro 39 40 depends_on: 40 41 plc: 41 42 condition: service_healthy
+7
hatk.config.ts
··· 42 42 redirect_uris: [ 43 43 `https://${prodDomain}/oauth/callback`, 44 44 `https://${prodDomain}/admin`, 45 + "grain://oauth/callback", 45 46 ], 46 47 }, 47 48 ] ··· 51 52 client_name: "grain", 52 53 scope: grainScopes, 53 54 redirect_uris: ["http://127.0.0.1:3000/oauth/callback", "http://127.0.0.1:3000/admin"], 55 + }, 56 + { 57 + client_id: "grain-native://app", 58 + client_name: "Grain for iOS", 59 + scope: grainScopes, 60 + redirect_uris: ["grain://oauth/callback"], 54 61 }, 55 62 ], 56 63 },
+3 -3
package-lock.json
··· 163 163 } 164 164 }, 165 165 "node_modules/@hatk/hatk": { 166 - "version": "0.0.1-alpha.44", 167 - "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.44.tgz", 168 - "integrity": "sha512-brHkesrJiGTGzt97vlNwSkAr/ueSgx6tq1p0dweYl1gFF4bNNjQb/q8Pa40GiI+Swh4L8hNWSyket9lR3kJHFw==", 166 + "version": "0.0.1-alpha.45", 167 + "resolved": "https://registry.npmjs.org/@hatk/hatk/-/hatk-0.0.1-alpha.45.tgz", 168 + "integrity": "sha512-y1+JB3uPr1tyUfu+XW4UXV4QuSJ+To6j+RyuINHlqjJ6j5OxaUZVVy5ZkzoEqQjqz4+H/RK1DrmB1KyYbjVBGQ==", 169 169 "license": "MIT", 170 170 "dependencies": { 171 171 "@bigmoves/lexicon": "^0.2.2",
+61
patches/pds-csrf-no-secure.js
··· 1 + "use strict"; 2 + // Patched for local HTTP development: Secure flag removed from CSRF cookie 3 + // so ASWebAuthenticationSession on iOS can send it over HTTP://localhost. 4 + // This file is mounted into the PDS container via docker-compose.yml. 5 + // See: @atproto/oauth-provider/dist/router/assets/csrf.js 6 + var __importDefault = (this && this.__importDefault) || function (mod) { 7 + return (mod && mod.__esModule) ? mod : { "default": mod }; 8 + }; 9 + Object.defineProperty(exports, "__esModule", { value: true }); 10 + exports.setupCsrfToken = setupCsrfToken; 11 + exports.validateCsrfToken = validateCsrfToken; 12 + exports.getCookieCsrf = getCookieCsrf; 13 + exports.getHeadersCsrf = getHeadersCsrf; 14 + const http_errors_1 = __importDefault(require("http-errors")); 15 + const oauth_provider_api_1 = require("@atproto/oauth-provider-api"); 16 + const index_js_1 = require("../../lib/http/index.js"); 17 + const crypto_js_1 = require("../../lib/util/crypto.js"); 18 + const TOKEN_BYTE_LENGTH = 12; 19 + const TOKEN_LENGTH = TOKEN_BYTE_LENGTH * 2; // 2 hex chars per byte 20 + const CSRF_COOKIE_OPTIONS = { 21 + expires: undefined, 22 + secure: false, // patched: was true, breaks iOS ASWebAuthenticationSession over HTTP 23 + httpOnly: false, 24 + sameSite: 'lax', 25 + path: `/`, 26 + }; 27 + async function generateCsrfToken() { 28 + return (0, crypto_js_1.randomHexId)(TOKEN_BYTE_LENGTH); 29 + } 30 + async function setupCsrfToken(req, res) { 31 + const token = getCookieCsrf(req) || (await generateCsrfToken()); 32 + (0, index_js_1.setCookie)(res, oauth_provider_api_1.CSRF_COOKIE_NAME, token, CSRF_COOKIE_OPTIONS); 33 + } 34 + async function validateCsrfToken(req, res) { 35 + const cookieValue = getCookieCsrf(req); 36 + const headerValue = getHeadersCsrf(req); 37 + (0, index_js_1.setCookie)(res, oauth_provider_api_1.CSRF_COOKIE_NAME, cookieValue || (await generateCsrfToken()), CSRF_COOKIE_OPTIONS); 38 + if (!headerValue) { 39 + throw (0, http_errors_1.default)(400, `Missing CSRF header`); 40 + } 41 + if (!cookieValue) { 42 + throw (0, http_errors_1.default)(400, `Missing CSRF cookie`); 43 + } 44 + if (cookieValue !== headerValue) { 45 + throw (0, http_errors_1.default)(400, `CSRF mismatch`); 46 + } 47 + } 48 + function getCookieCsrf(req) { 49 + const cookieValue = (0, index_js_1.getCookie)(req, oauth_provider_api_1.CSRF_COOKIE_NAME); 50 + if (cookieValue?.length === TOKEN_LENGTH) { 51 + return cookieValue; 52 + } 53 + return undefined; 54 + } 55 + function getHeadersCsrf(req) { 56 + const headerValue = req.headers[oauth_provider_api_1.CSRF_HEADER_NAME]; 57 + if (typeof headerValue === 'string' && headerValue.length === TOKEN_LENGTH) { 58 + return headerValue; 59 + } 60 + return undefined; 61 + }