Replace IDP token passthrough with backend-issued JWT for iOS auth

iOS clients now exchange the OIDC authorization code for a backend-signed
HS256 JWT via POST /auth/token. All subsequent API requests authenticate
using this JWT as a Bearer token, verified locally — no per-request IDP
call is needed. Web frontend session-cookie auth is unchanged.
This commit is contained in:
2026-02-19 18:45:03 +01:00
parent 1ca76b0fec
commit 946cd35832
10 changed files with 662 additions and 85 deletions

View File

@@ -13,6 +13,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"jsonwebtoken": "^9.0.3",
"openid-client": "^5.6.1", "openid-client": "^5.6.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -20,12 +21,285 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.17.10", "@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
@@ -43,6 +317,159 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
@@ -209,6 +636,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -216,6 +654,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.2.3", "version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
@@ -316,6 +761,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -570,6 +1021,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -828,6 +1288,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1007,6 +1482,97 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -1423,6 +1989,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",

View File

@@ -15,6 +15,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"jsonwebtoken": "^9.0.3",
"openid-client": "^5.6.1", "openid-client": "^5.6.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -22,6 +23,7 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.17.10", "@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"tsx": "^4.7.0", "tsx": "^4.7.0",

45
backend/src/auth/jwt.ts Normal file
View File

@@ -0,0 +1,45 @@
import jwt from 'jsonwebtoken';
import { config } from '../config';
import type { AuthenticatedUser } from '../types';
export interface JwtPayload {
sub: string;
username: string;
fullName: string | null;
email: string;
}
/**
* Mint a backend-signed JWT for a native (iOS) client.
* The token is self-contained — no IDP call is needed to verify it.
*/
export function signBackendJwt(user: AuthenticatedUser): string {
const payload: JwtPayload = {
sub: user.id,
username: user.username,
fullName: user.fullName,
email: user.email,
};
return jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
algorithm: 'HS256',
});
}
/**
* Verify a backend-signed JWT and return the encoded user.
* Throws if the token is invalid or expired.
*/
export function verifyBackendJwt(token: string): AuthenticatedUser {
const payload = jwt.verify(token, config.jwt.secret, {
algorithms: ['HS256'],
}) as JwtPayload;
return {
id: payload.sub,
username: payload.username,
fullName: payload.fullName,
email: payload.email,
};
}

View File

@@ -2,6 +2,9 @@ import { Issuer, generators, Client, TokenSet } from 'openid-client';
import { config } from '../config'; import { config } from '../config';
import type { AuthenticatedUser } from '../types'; import type { AuthenticatedUser } from '../types';
// Note: bearer-token (JWT) verification for native clients lives in auth/jwt.ts.
// This module is responsible solely for the OIDC protocol flows.
let oidcClient: Client | null = null; let oidcClient: Client | null = null;
export async function initializeOIDC(): Promise<void> { export async function initializeOIDC(): Promise<void> {
@@ -160,48 +163,3 @@ export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser
return { id, username, fullName, email }; return { id, username, fullName, email };
} }
export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
try {
const client = getOIDCClient();
await client.userinfo(tokenSet);
return true;
} catch {
return false;
}
}
// Cache userinfo responses to avoid hitting the OIDC provider on every request.
// Entries expire after 5 minutes. The cache is keyed by the raw access token.
const userinfoCache = new Map<string, { user: AuthenticatedUser; expiresAt: number }>();
const USERINFO_CACHE_TTL_MS = 5 * 60 * 1000;
export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> {
const cached = userinfoCache.get(accessToken);
if (cached && Date.now() < cached.expiresAt) {
return cached.user;
}
const client = getOIDCClient();
let userInfo: Awaited<ReturnType<typeof client.userinfo>>;
try {
userInfo = await client.userinfo(accessToken);
} catch (err) {
// Remove any stale cache entry for this token
userinfoCache.delete(accessToken);
throw err;
}
const id = String(userInfo.sub);
const username = String(userInfo.preferred_username || userInfo.name || id);
const email = String(userInfo.email || '');
const fullName = String(userInfo.name || '') || null;
if (!email) {
throw new Error('Email not provided by OIDC provider');
}
const user: AuthenticatedUser = { id, username, fullName, email };
userinfoCache.set(accessToken, { user, expiresAt: Date.now() + USERINFO_CACHE_TTL_MS });
return user;
}

View File

@@ -25,6 +25,13 @@ export const config = {
maxAge: 24 * 60 * 60 * 1000, // 24 hours maxAge: 24 * 60 * 60 * 1000, // 24 hours
}, },
jwt: {
// Dedicated secret for backend-issued JWTs. Falls back to SESSION_SECRET so
// existing single-secret deployments work without any config change.
secret: process.env.JWT_SECRET || process.env.SESSION_SECRET || "default-secret-change-in-production",
expiresIn: 30 * 24 * 60 * 60, // 30 days in seconds
},
cors: { cors: {
origin: process.env.APP_URL || "http://localhost:5173", origin: process.env.APP_URL || "http://localhost:5173",
credentials: true, credentials: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client'; import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { getOIDCClient, verifyBearerToken } from '../auth/oidc'; import { verifyBackendJwt } from '../auth/jwt';
export async function requireAuth( export async function requireAuth(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@@ -14,18 +14,16 @@ export async function requireAuth(
return next(); return next();
} }
// 2. Bearer token auth (iOS / native clients) // 2. Bearer JWT auth (iOS / native clients)
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
console.log('[requireAuth] authorization header:', authHeader ? `${authHeader.slice(0, 20)}` : '(none)');
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const accessToken = authHeader.slice(7); const token = authHeader.slice(7);
try { try {
const user = await verifyBearerToken(accessToken); // Verify the backend-signed JWT locally — no IDP network call needed.
req.user = user; req.user = verifyBackendJwt(token);
return next(); return next();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error('[requireAuth] verifyBearerToken failed:', err);
res.status(401).json({ error: `Unauthorized: ${message}` }); res.status(401).json({ error: `Unauthorized: ${message}` });
return; return;
} }

View File

@@ -7,8 +7,9 @@ import {
exchangeNativeCode, exchangeNativeCode,
getUserInfo, getUserInfo,
} from "../auth/oidc"; } from "../auth/oidc";
import { signBackendJwt } from "../auth/jwt";
import { requireAuth, syncUser } from "../middleware/auth"; import { requireAuth, syncUser } from "../middleware/auth";
import type { AuthenticatedRequest, AuthenticatedUser } from "../types"; import type { AuthenticatedRequest } from "../types";
import type { AuthSession } from "../auth/oidc"; import type { AuthSession } from "../auth/oidc";
const router = Router(); const router = Router();
@@ -119,9 +120,10 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
res.json(req.user); res.json(req.user);
}); });
// POST /auth/token - Exchange authorization code for tokens (native app flow) // POST /auth/token - Exchange OIDC authorization code for a backend JWT (native app flow).
// Session state is retrieved from the in-memory store by state value, so no // The iOS app calls this after the OIDC redirect; it receives a backend-signed JWT which
// session cookie is required from the native client. // it then uses as a Bearer token for all subsequent API requests. The backend verifies
// this JWT locally — no per-request IDP call is needed.
router.post("/token", async (req, res) => { router.post("/token", async (req, res) => {
try { try {
await ensureOIDC(); await ensureOIDC();
@@ -140,15 +142,16 @@ router.post("/token", async (req, res) => {
} }
const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri); const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri);
const user = await getUserInfo(tokenSet); const user = await getUserInfo(tokenSet);
await syncUser(user); await syncUser(user);
// Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>.
const backendJwt = signBackendJwt(user);
res.json({ res.json({
access_token: tokenSet.access_token, access_token: backendJwt,
id_token: tokenSet.id_token, token_type: "Bearer",
token_type: tokenSet.token_type, expires_in: 30 * 24 * 60 * 60, // 30 days
expires_in: tokenSet.expires_in,
user, user,
}); });
} catch (error) { } catch (error) {

View File

@@ -11,6 +11,7 @@ final class AuthManager: ObservableObject {
private let keychain: Keychain private let keychain: Keychain
private let apiClient = APIClient() private let apiClient = APIClient()
/// The backend-issued JWT. Sent as `Authorization: Bearer <token>` on every API call.
var accessToken: String? { var accessToken: String? {
get { try? keychain.get(AppConstants.KeychainKeys.accessToken) } get { try? keychain.get(AppConstants.KeychainKeys.accessToken) }
set { set {
@@ -22,17 +23,6 @@ final class AuthManager: ObservableObject {
} }
} }
var idToken: String? {
get { try? keychain.get(AppConstants.KeychainKeys.idToken) }
set {
if let value = newValue {
try? keychain.set(value, key: AppConstants.KeychainKeys.idToken)
} else {
try? keychain.remove(AppConstants.KeychainKeys.idToken)
}
}
}
private init() { private init() {
self.keychain = Keychain(service: "com.timetracker.app") self.keychain = Keychain(service: "com.timetracker.app")
.accessibility(.whenUnlockedThisDeviceOnly) .accessibility(.whenUnlockedThisDeviceOnly)
@@ -66,7 +56,9 @@ final class AuthManager: ObservableObject {
} }
func logout() async throws { func logout() async throws {
try await apiClient.requestVoid( // Best-effort server-side logout; the backend JWT is stateless so the
// real security comes from clearing the local token.
try? await apiClient.requestVoid(
endpoint: APIEndpoint.logout, endpoint: APIEndpoint.logout,
method: .post, method: .post,
authenticated: true authenticated: true
@@ -76,14 +68,12 @@ final class AuthManager: ObservableObject {
func clearAuth() { func clearAuth() {
accessToken = nil accessToken = nil
idToken = nil
currentUser = nil currentUser = nil
isAuthenticated = false isAuthenticated = false
} }
func handleTokenResponse(_ response: TokenResponse) async { func handleTokenResponse(_ response: TokenResponse) async {
accessToken = response.accessToken accessToken = response.accessToken
idToken = response.idToken
currentUser = response.user currentUser = response.user
isAuthenticated = true isAuthenticated = true
} }

View File

@@ -31,8 +31,8 @@ final class AuthService: NSObject {
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker" let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
// Use a shared (non-ephemeral) session so the backend session cookie set during // Use an ephemeral session we only need the redirect URL back with the
// /auth/login is automatically included in the /auth/token POST. // authorization code; no cookies or shared state are needed.
let webAuthSession = ASWebAuthenticationSession( let webAuthSession = ASWebAuthenticationSession(
url: authURL, url: authURL,
callbackURLScheme: callbackScheme callbackURLScheme: callbackScheme
@@ -69,9 +69,8 @@ final class AuthService: NSObject {
} }
webAuthSession.presentationContextProvider = self webAuthSession.presentationContextProvider = self
// prefersEphemeralWebBrowserSession = false ensures the session cookie from // Ephemeral session: no shared cookies or browsing data with Safari.
// /auth/login is retained and sent with the subsequent /auth/token request. webAuthSession.prefersEphemeralWebBrowserSession = true
webAuthSession.prefersEphemeralWebBrowserSession = false
self.authSession = webAuthSession self.authSession = webAuthSession
@@ -138,8 +137,8 @@ final class AuthService: NSObject {
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// state is sent so the backend can look up and validate the original session. // state is sent so the backend can look up the original PKCE session.
// code_verifier is not sent the backend uses its own verifier from the session. // code_verifier is NOT sent the backend holds it in the in-memory session.
let body: [String: Any] = [ let body: [String: Any] = [
"code": code, "code": code,
"state": state, "state": state,
@@ -198,14 +197,12 @@ extension Notification.Name {
struct TokenResponse: Codable { struct TokenResponse: Codable {
let accessToken: String let accessToken: String
let idToken: String?
let tokenType: String let tokenType: String
let expiresIn: Int? let expiresIn: Int?
let user: User let user: User
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case accessToken = "access_token" case accessToken = "access_token"
case idToken = "id_token"
case tokenType = "token_type" case tokenType = "token_type"
case expiresIn = "expires_in" case expiresIn = "expires_in"
case user case user

View File

@@ -13,7 +13,6 @@ enum AppConstants {
enum KeychainKeys { enum KeychainKeys {
static let accessToken = "accessToken" static let accessToken = "accessToken"
static let idToken = "idToken"
} }
} }