Skip to content

Commit c0340ec

Browse files
committed
add working ssr authentication
1 parent 068b0d9 commit c0340ec

13 files changed

Lines changed: 1604 additions & 125 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
22

33
# dependencies
4-
/node_modules
4+
node_modules
55
/.pnp
66
.pnp.js
77

scripts/iap-test/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.env
2+
sa-key.json

scripts/iap-test/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# IAP Test Scripts (IAP + GCIP)
2+
3+
This folder contains two minimal scripts to test calling an IAP-protected API:
4+
5+
- `fetch-iap.js`: Uses a Google OIDC ID token (audience = IAP OAuth Client ID). Works when IAP uses Google accounts.
6+
- `fetch-gcip.js`: Uses an Identity Platform (GCIP) ID token obtained via custom token flow. Use this when IAP is configured with GCIP.
7+
8+
## Environment
9+
Create `.env` based on `.env.sample`:
10+
11+
```env
12+
# Common
13+
API_URL=https://<your-iap-domain>/<path>
14+
GOOGLE_SA_JSON_PATH=./sa-key.json
15+
16+
# IAP (Google accounts)
17+
IAP_AUDIENCE=<your-iap-oauth-client-id>
18+
19+
# GCIP (Identity Platform)
20+
GCIP_PROJECT_ID=<your-project-id>
21+
GCIP_API_KEY=<identity-platform-api-key>
22+
# optional if using multi-tenant
23+
GCIP_TENANT_ID=<tenant-id>
24+
# optional synthetic UID for the service caller
25+
GCIP_SERVICE_UID=iap-service-caller
26+
```
27+
28+
## Install & Run
29+
```bash
30+
# Install local-only deps
31+
npm i google-auth-library firebase-admin node-fetch dotenv
32+
33+
# IAP (Google accounts) flow
34+
node fetch-iap.js
35+
36+
# GCIP (Identity Platform) flow
37+
node fetch-gcip.js
38+
```
39+
40+
### Using Yarn
41+
```bash
42+
# Install
43+
yarn add google-auth-library firebase-admin node-fetch dotenv
44+
45+
# IAP flow
46+
yarn run test:iap
47+
48+
# GCIP flow (default)
49+
yarn test
50+
```
51+
52+
## Notes
53+
- `GCIP_API_KEY` is the Web API key for Identity Platform (Firebase/GCIP). Find it in Google Cloud Console → APIs & Services → Credentials.
54+
- For GCIP multi-tenant, set `GCIP_TENANT_ID` and ensure the IAP resource is linked to the same tenant.
55+
- The Service Account must have access to mint custom tokens and (optionally) act as a service principal.# IAP ID Token Test Script
56+
57+
Minimal Node.js script to fetch a Google-signed OIDC ID token for an IAP-protected HTTPS resource and perform a request to your API.
58+
59+
## Prerequisites
60+
- Node.js 18+ installed
61+
- Service Account with role `roles/iap.httpsResourceAccessor`
62+
- IAP OAuth 2.0 Client ID (the audience)
63+
64+
## Setup
65+
1. Create a service account key locally (for testing only; rotate/secure it):
66+
```bash
67+
PROJECT_ID=<your-project-id>
68+
gcloud iam service-accounts keys create sa-key.json \
69+
--iam-account=vercel-iap-caller@${PROJECT_ID}.iam.gserviceaccount.com
70+
```
71+
72+
2. Copy `.env.sample` to `.env` and fill values:
73+
```bash
74+
cp .env.sample .env
75+
```
76+
77+
3. Install dependencies and run:
78+
```bash
79+
npm ci
80+
npm run test
81+
```
82+
83+
## Environment Variables
84+
- `IAP_AUDIENCE`: The IAP OAuth client ID (…apps.googleusercontent.com)
85+
- `API_URL`: Full URL to a reachable endpoint (e.g., https://<your-iap-domain>/health)
86+
- `GOOGLE_SA_JSON_PATH` (preferred): Path to SA key JSON file (e.g., `./sa-key.json`).
87+
- `GOOGLE_SA_JSON` (optional): Inline JSON string of SA key (used if `GOOGLE_SA_JSON_PATH` not provided).
88+
- `LOG_JWT_CLAIMS` (optional): `true|false` print decoded JWT claims for verification.
89+
90+
## Notes
91+
- For production (Vercel), store secrets in environment variables and avoid files.
92+
- The audience must exactly match the IAP OAuth client ID configured on the protected resource.

scripts/iap-test/fetch-gcip.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* GCIP (Identity Platform) token flow for IAP when IAP is configured with GCIP.
3+
* 1) Use Firebase Admin to mint a custom token for a synthetic service user.
4+
* 2) Exchange the custom token for a GCIP ID token via Identity Toolkit API.
5+
* 3) Call the IAP-protected API with Authorization: Bearer <GCIP ID token>.
6+
*/
7+
8+
const fs = require('fs');
9+
const path = require('path');
10+
const fetch = require('node-fetch');
11+
const admin = require('firebase-admin');
12+
require('dotenv').config();
13+
14+
function ensureEnv(name) {
15+
const v = process.env[name];
16+
if (!v) {
17+
console.error(`Missing ${name}`);
18+
process.exit(2);
19+
}
20+
return v;
21+
}
22+
23+
async function main() {
24+
const apiUrl = ensureEnv('API_URL');
25+
const saJsonPath = process.env.GOOGLE_SA_JSON_PATH;
26+
const saJsonInline = process.env.GOOGLE_SA_JSON;
27+
const projectId = ensureEnv('GCIP_PROJECT_ID');
28+
const apiKey = ensureEnv('GCIP_API_KEY');
29+
const tenantId = process.env.GCIP_TENANT_ID || undefined; // optional if default tenant
30+
const serviceUid = process.env.GCIP_SERVICE_UID || 'iap-service-caller';
31+
32+
let credentials;
33+
if (saJsonPath && fs.existsSync(path.resolve(saJsonPath))) {
34+
credentials = JSON.parse(fs.readFileSync(path.resolve(saJsonPath), 'utf8'));
35+
} else if (saJsonInline) {
36+
credentials = JSON.parse(saJsonInline);
37+
} else {
38+
console.error('Provide GOOGLE_SA_JSON_PATH or GOOGLE_SA_JSON');
39+
process.exit(2);
40+
}
41+
42+
if (!credentials.client_email || !credentials.private_key) {
43+
console.error('Service Account JSON must include client_email and private_key');
44+
process.exit(2);
45+
}
46+
47+
if (!admin.apps.length) {
48+
admin.initializeApp({
49+
credential: admin.credential.cert(credentials),
50+
projectId,
51+
});
52+
}
53+
54+
// Mint a custom token for a synthetic service user UID.
55+
const customToken = await admin.auth().createCustomToken(serviceUid, {
56+
service: true,
57+
});
58+
59+
// Exchange the custom token for an ID token.
60+
const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`;
61+
const body = {
62+
token: customToken,
63+
returnSecureToken: true,
64+
};
65+
if (tenantId) body.tenantId = tenantId;
66+
67+
const resp = await fetch(url, {
68+
method: 'POST',
69+
headers: { 'Content-Type': 'application/json' },
70+
body: JSON.stringify(body),
71+
});
72+
if (!resp.ok) {
73+
const errText = await resp.text();
74+
console.error('Failed to exchange custom token:', resp.status, errText);
75+
process.exit(1);
76+
}
77+
const tokens = await resp.json();
78+
const idToken = tokens.idToken;
79+
if (!idToken) {
80+
console.error('No idToken in response');
81+
process.exit(1);
82+
}
83+
84+
// Call the IAP-protected API with GCIP ID token.
85+
console.log(`Calling ${apiUrl} with GCIP ID token (tenant: ${tenantId || 'default'})...`);
86+
const apiResp = await fetch(apiUrl, {
87+
headers: { Authorization: `Bearer ${idToken}` },
88+
});
89+
console.log('Status:', apiResp.status);
90+
const data = await apiResp.text();
91+
if (apiResp.status >= 400) {
92+
console.error('Error body:', data);
93+
process.exit(1);
94+
}
95+
try {
96+
console.log(JSON.stringify(JSON.parse(data), null, 2));
97+
} catch {
98+
console.log(data);
99+
}
100+
}
101+
102+
main().catch((e) => {
103+
console.error('Unhandled error:', e);
104+
process.exit(1);
105+
});

scripts/iap-test/fetch-iap.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Minimal script to acquire an IAP OIDC ID token and call the API.
3+
* Reads configuration from environment variables (see .env.sample).
4+
*/
5+
6+
const fs = require('fs');
7+
const path = require('path');
8+
const { GoogleAuth } = require('google-auth-library');
9+
require('dotenv').config();
10+
11+
function base64UrlDecode(input) {
12+
// Pad input to multiple of 4 and replace URL-safe chars
13+
input = input.replace(/-/g, '+').replace(/_/g, '/');
14+
const pad = input.length % 4;
15+
if (pad) input += '='.repeat(4 - pad);
16+
return Buffer.from(input, 'base64').toString('utf8');
17+
}
18+
19+
function decodeJwt(jwt) {
20+
try {
21+
const [header, payload] = jwt.split('.');
22+
const h = JSON.parse(base64UrlDecode(header));
23+
const p = JSON.parse(base64UrlDecode(payload));
24+
return { header: h, payload: p };
25+
} catch (e) {
26+
return null;
27+
}
28+
}
29+
30+
async function main() {
31+
const audience = process.env.IAP_AUDIENCE;
32+
const apiUrl = process.env.API_URL;
33+
const saJsonPath = process.env.GOOGLE_SA_JSON_PATH;
34+
const saJsonInline = process.env.GOOGLE_SA_JSON;
35+
const logClaims = String(process.env.LOG_JWT_CLAIMS || 'false').toLowerCase() === 'true';
36+
37+
if (!audience) {
38+
console.error('Missing IAP_AUDIENCE');
39+
process.exit(2);
40+
}
41+
if (!apiUrl) {
42+
console.error('Missing API_URL');
43+
process.exit(2);
44+
}
45+
46+
let credentials;
47+
if (saJsonPath && fs.existsSync(path.resolve(saJsonPath))) {
48+
credentials = JSON.parse(fs.readFileSync(path.resolve(saJsonPath), 'utf8'));
49+
} else if (saJsonInline) {
50+
credentials = JSON.parse(saJsonInline);
51+
} else {
52+
console.error('Provide GOOGLE_SA_JSON_PATH or GOOGLE_SA_JSON');
53+
process.exit(2);
54+
}
55+
56+
const auth = new GoogleAuth({ credentials });
57+
const client = await auth.getIdTokenClient(audience);
58+
59+
// Intercept to print the ID token (and optionally decode claims)
60+
const origRequest = client.request.bind(client);
61+
client.request = async (opts) => {
62+
const res = await origRequest(opts);
63+
return res;
64+
};
65+
66+
// The getRequestHeaders() includes Authorization header; we can decode claims for sanity
67+
const headers = await client.getRequestHeaders();
68+
const token = (headers.Authorization || headers.authorization || '').replace(/^Bearer\s+/i, '');
69+
if (!token) {
70+
console.error('Failed to obtain ID token');
71+
process.exit(1);
72+
}
73+
if (logClaims) {
74+
const decoded = decodeJwt(token);
75+
if (decoded) {
76+
console.log('JWT header:', JSON.stringify(decoded.header, null, 2));
77+
console.log('JWT payload:', JSON.stringify(decoded.payload, null, 2));
78+
if (decoded.payload && decoded.payload.iss) {
79+
console.log('JWT issuer:', decoded.payload.iss);
80+
}
81+
if (decoded.payload && decoded.payload.aud !== audience) {
82+
console.warn('Warning: token aud does not match IAP_AUDIENCE');
83+
}
84+
}
85+
}
86+
87+
console.log(`Calling ${apiUrl} with IAP audience ${audience}...`);
88+
try {
89+
const res = await client.request({ url: apiUrl });
90+
console.log('Status:', res.status);
91+
if (res.status >= 400) {
92+
console.error('Error body:', res.data);
93+
process.exit(1);
94+
}
95+
console.log(typeof res.data === 'string' ? res.data : JSON.stringify(res.data));
96+
} catch (err) {
97+
if (err.response) {
98+
console.error('Request failed:', err.response.status, err.response.data);
99+
const body = typeof err.response.data === 'string' ? err.response.data : JSON.stringify(err.response.data);
100+
if (err.response.status === 401 && /Invalid GCIP ID token/i.test(body)) {
101+
console.error('Hint: IAP is configured with Identity Platform (GCIP). Use fetch-gcip.js instead of fetch-iap.js.');
102+
}
103+
} else {
104+
console.error('Error:', err.message);
105+
}
106+
process.exit(1);
107+
}
108+
}
109+
110+
main();

scripts/iap-test/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "iap-test",
3+
"private": true,
4+
"version": "0.1.0",
5+
"description": "IAP + GCIP token test scripts for IAP-protected API calls",
6+
"license": "UNLICENSED",
7+
"type": "commonjs",
8+
"scripts": {
9+
"test:iap": "node fetch-iap.js",
10+
"test:gcip": "node fetch-gcip.js",
11+
"test": "node fetch-gcip.js"
12+
},
13+
"dependencies": {
14+
"dotenv": "^16.4.5",
15+
"firebase-admin": "^12.3.0",
16+
"google-auth-library": "^9.14.2",
17+
"node-fetch": "^2.6.9"
18+
}
19+
}

0 commit comments

Comments
 (0)