diff --git a/package.json b/package.json index b239c7ad54..8c8ba1ccc6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "eslint-plugin-n": "^14.0.0", "eslint-plugin-prettier": "^5.0.0-alpha.1", "gts": "5.3.0", + "http-status-codes": "^2.3.0", "mocha": "^10.2.0", "nunjucks": "^3.2.4", "prettier": "^3.0.3", diff --git a/run/service-auth/index.js b/run/service-auth/index.js new file mode 100644 index 0000000000..801327430d --- /dev/null +++ b/run/service-auth/index.js @@ -0,0 +1,38 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const express = require('express'); +const {receiveRequestAndParseAuthHeader} = require('./receive'); + +const app = express(); + +app.get('/', async (req, res) => { + try { + const response = await receiveRequestAndParseAuthHeader(req); + + const status = response.includes('Hello') ? 200 : 401; + res.status(status).send(response); + } catch (e) { + res.status(401).send(`Error verifying ID token: ${e.message}`); + } +}); + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); +}); + +module.exports = app; diff --git a/run/service-auth/package.json b/run/service-auth/package.json new file mode 100644 index 0000000000..07bb8f7973 --- /dev/null +++ b/run/service-auth/package.json @@ -0,0 +1,29 @@ +{ + "name": "service-auth", + "description": "Node.js samples for authenticated service-to-service communication", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "author": "Google LLC", + "engines": { + "node": "20.x" + }, + "scripts": { + "start": "node index.js", + "deploy": "gcloud run deploy service-auth --source .", + "unit-test": "c8 mocha -p -j 2 test/ --timeout=10000 --exit", + "test": "npm run unit-test" + }, + "dependencies": { + "express": "^4.17.1", + "google-auth-library": "^9.0.0" + }, + "devDependencies": { + "axios": "^1.8.4", + "c8": "^10.0.0", + "chai": "^4.5.0", + "mocha": "^10.0.0", + "sinon": "^18.0.0", + "uuid": "^11.1.0" + } +} diff --git a/run/service-auth/receive.js b/run/service-auth/receive.js new file mode 100644 index 0000000000..5049a41b9f --- /dev/null +++ b/run/service-auth/receive.js @@ -0,0 +1,60 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +async function main(req) { + // [START auth_validate_and_decode_bearer_token_on_express] + // [START cloudrun_service_to_service_receive] + const {OAuth2Client} = require('google-auth-library'); + + const client = new OAuth2Client(); + + // Inner function that parses and verifies the token. + async function receiveRequestAndParseAuthHeader(request) { + const authHeader = request.headers.authorization; + if (authHeader) { + // Split the auth type and token value from the Authorization header. + const [type, token] = authHeader.split(' '); + + if (type.toLowerCase() === 'bearer') { + // More info on verifyIdToken: + // https://github.com/googleapis/google-auth-library-nodejs/blob/main/samples/verifyIdToken-iap.js + try { + const ticket = await client.verifyIdToken({ + idToken: token, + audience: process.env.CLIENT_ID, + }); + const payload = ticket.getPayload(); + console.log(`Hello, ${payload.email}!\n`); + return; + } catch (err) { + console.log(`Invalid token: ${err.message}\n`); + return; + } + } else { + console.log(`Unhandled header format(${type}).\n`); + return; + } + } + + console.log('Hello, anonymous user.\n'); + } + + await receiveRequestAndParseAuthHeader(req); +} +// [END cloudrun_service_to_service_receive] +// [END auth_validate_and_decode_bearer_token_on_express] + +module.exports = {main}; diff --git a/run/service-auth/test/receive.test.js b/run/service-auth/test/receive.test.js new file mode 100644 index 0000000000..9c4d36812d --- /dev/null +++ b/run/service-auth/test/receive.test.js @@ -0,0 +1,97 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +'use strict'; + +const {expect} = require('chai'); +const axios = require('axios'); +const {execSync} = require('child_process'); +const {v4: uuidv4} = require('uuid'); +const {StatusCodes} = require('http-status-codes'); + +const TEST_INVALID_TOKEN = 'test-invalid-token'; +const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT; +const REGION = 'us-central1'; + +describe('receiveRequestAndParseAuthHeader sample (Cloud Run integration)', function () { + this.timeout(5 * 60 * 1000); // 5 minutes for deploy + test + const serviceName = `receive-${uuidv4().slice(0, 8)}`; + let serviceUrl; + + before(() => { + console.log(`Deploying Cloud Run service: ${serviceName}...`); + execSync( + `gcloud run deploy ${serviceName} \ + --project=${PROJECT_ID} \ + --region=${REGION} \ + --source=. \ + --allow-unauthenticated \ + --quiet`, + {stdio: 'inherit'} + ); + + console.log('Fetching service URL...'); + serviceUrl = execSync(`gcloud run services describe ${serviceName} \ + --project=${PROJECT_ID} \ + --region=${REGION} \ + --format='value(status.url)'`) + .toString() + .trim(); + + console.log(`Service URL: ${serviceUrl}`); + }); + + after(() => { + console.log(`Deleting service: ${serviceName}...`); + execSync(`gcloud run services delete ${serviceName} \ + --project=${PROJECT_ID} \ + --region=${REGION} \ + --quiet`); + }); + + function getIdentityToken() { + return execSync('gcloud auth print-identity-token').toString().trim(); + } + + it('should respond with greeting if token is valid', async () => { + const token = getIdentityToken(); + const res = await axios.get(serviceUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).to.equal(StatusCodes.OK); + expect(res.data).to.match(/^Hello, .@.\.\w+!\n$/); + }); + + it('should respond with anonymous if no token is provided', async () => { + const res = await axios.get(serviceUrl); + expect(res.status).to.equal(StatusCodes.OK); + expect(res.data).to.equal('Hello, anonymous user.\n'); + }); + + it('should respond with unoauthorized if token is invalid', async () => { + try { + await axios.get(serviceUrl, { + headers: { + Authorization: `Bearer ${TEST_INVALID_TOKEN}`, + }, + }); + } catch (err) { + expect(err.response.status).to.equal(StatusCodes.UNAUTHORIZED); + expect(err.response.data).to.include('Invalid token'); + } + }); +});