From 9ba113df0c4ba8a799ed5f13ccf41383f571a3e1 Mon Sep 17 00:00:00 2001 From: Stefano Rivoir Date: Mon, 13 Oct 2025 17:16:18 +0200 Subject: [PATCH] Support Google OAuth2 token authentication Fixes #413 --- api/v1/controllers/login.mjs | 50 ++++++++++++++++++++++++++-- config-skel.json | 7 +++- docs/apidoc/paths/login.yaml | 6 +++- docs/apidoc/requestbodies/login.yaml | 5 ++- docs/index.md | 13 ++++++++ lib/schemas/login.json | 9 ++--- lib/schemas/system_config.json | 14 +++++++- 7 files changed, 94 insertions(+), 10 deletions(-) diff --git a/api/v1/controllers/login.mjs b/api/v1/controllers/login.mjs index 328ba21..07453a7 100644 --- a/api/v1/controllers/login.mjs +++ b/api/v1/controllers/login.mjs @@ -19,9 +19,18 @@ import * as Settings from '../../../lib/settings.mjs' import * as ApiKey from '../../../model/apikey.mjs' import * as Metrics from '../../../lib/metrics.mjs' import * as Folder from '../../../model/folder.mjs' +import * as GOAuth2 from 'google-auth-library' import DB from '../../../lib/db.mjs' +// Google OAuth2 setup +let GOAuth2Client = null +if (Config.get().auth?.google_oauth2?.enabled === true) { + GOAuth2Client = new GOAuth2.OAuth2Client( + process.env.GOOGLE_CLIENT_ID + ) +} + /** * Login * @param {Object} req Express request @@ -40,12 +49,14 @@ export async function login (req, res, next) { username: req.body.username?.toLowerCase() || '', password: req.body?.password || '', apikey: req.body?.apikey || '', - secret: req.body?.secret || '' + secret: req.body?.secret || '', + googleoauth2token: req.body?.googleoauth2token || '' } // If an API key is provided, validate it and get the user let isapikey = false let apikeydescription = '' + let isGoogleToken = false if (data.apikey && data.secret) { if (!await ApiKey.exists(data.apikey)) { await Events.add(data.username, Const.EV_ACTION_LOGIN_APIKEY_NOTFOUND, Const.EV_ENTITY_APIKEY, data.apikey) @@ -109,6 +120,41 @@ export async function login (req, res, next) { isapikey = true } + // If a Google OAuth2 token is provided, validate it and get the user + if (GOAuth2Client && data.googleoauth2token) { + try { + // Extract info from ID token + const ticket = await GOAuth2Client.verifyIdToken({ + idToken: data.googleoauth2token, + audience: process.env.GOOGLE_CLIENT_ID + }) + const payload = ticket.getPayload() + + if (!payload?.email) { + res.status(R.UNAUTHORIZED).send(R.ko('Google OAuth2 authentication failed')) + return + } + + // Get corresponding user + const user = await DB.users.findFirst({ + where: { email: payload.email }, + select: { login: true } + }) + + if (user === null) { + await Events.add(payload.email, Const.EV_ACTION_LOGIN_USERNOTFOUND, Const.EV_ENTITY_USER, payload.email) + res.status(R.UNAUTHORIZED).send(R.ko('Bad user or wrong password')) + return + } + + data.username = user.login + isGoogleToken = true + } catch (err) { + res.status(R.UNAUTHORIZED).send(R.ko('Google OAuth2 authentication failed')) + return + } + } + // Check user const user = await DB.users.findUnique({ where: { login: data.username } @@ -193,7 +239,7 @@ export async function login (req, res, next) { } // Local authentication - if (!isapikey && user.authmethod === 'local') { + if (!isapikey && !isGoogleToken && user.authmethod === 'local') { if (!await Crypt.checkPassword(data.password, user.secret)) { await Events.add(null, Const.EV_ACTION_LOGINFAILED, Const.EV_ENTITY_USER, data.username) res.status(R.UNAUTHORIZED).send(R.ko('Bad user or wrong password')) diff --git a/config-skel.json b/config-skel.json index dd1f523..ca8de67 100644 --- a/config-skel.json +++ b/config-skel.json @@ -40,5 +40,10 @@ "readonly": false, "enable_metrics": true, "generated_password_length": 15, - "cache-control": "" + "cache-control": "", + "auth": { + "google_oauth2": { + "enabled": true + } + } } diff --git a/docs/apidoc/paths/login.yaml b/docs/apidoc/paths/login.yaml index 03a8690..ad9f9b7 100644 --- a/docs/apidoc/paths/login.yaml +++ b/docs/apidoc/paths/login.yaml @@ -5,7 +5,11 @@ post: summary: Login description: | - Login in and return a JWT token; username and password must be provided for regular login, while apikey and secret are used for API keys logins. + Login in and return a JWT token: + - `username` and `password` must be provided for regular login + - `apikey` and `secret` are used for API keys logins. + - `googleoauth2token` (Google OAuth2 token) can be provided to validate the token and log the user in + The returned HS512 signed JWT token must be used in subsequent requests. requestBody: required: true diff --git a/docs/apidoc/requestbodies/login.yaml b/docs/apidoc/requestbodies/login.yaml index c0ba024..b6faf82 100644 --- a/docs/apidoc/requestbodies/login.yaml +++ b/docs/apidoc/requestbodies/login.yaml @@ -14,4 +14,7 @@ loginBody: example: abcd secret: type: string - example: abcd \ No newline at end of file + example: abcd + googleoauth2token: + type: string + example: ya29.a0AfH6SMB... \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 246ab93..7a7c4ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,6 +27,7 @@ This are the software you need to have in order to run PassWeaver API: These are the features this API support, in random order: - Cloud KMS integration (currently, only Google Cloud KMS) +- Login via Google OAuth2 token validation - API keys, with IP whitelist and day of week/time whitelist - Personal folders for each user - Favorite items @@ -143,6 +144,14 @@ PassWeaver API users can be authenticated via these methods: - Local: the user password hash is stored locally in the database - LDAP: authenticate against a LDAP/Active Directory server - API key: authenticate only via an existing API key + - Google OAuth2 token validation: see below + +### Google OAuth2 token validation + +You can integrate your frontend with Google OAuth2 (PassWeaver GUI supports is), and once you obtain a valid token PassWeaver API can validate it and obtain the informations to log you in: +it will look for an existing user with the email obtained from the token. + +In order to enable Google OAuth2, you have to set auth.google_oauth2 in the configuration, and export GOOGLE_CLIENT_ID of your Google API Key in your environment. ## Authorization @@ -434,6 +443,10 @@ Copy `config-skel.json` to `config.json` and adjust the options (all options are - `enable_metrics`: true or false, enables Prometheus-formatted metrics - `generated_password_length`: default length of random generated password (default is 15) - `cache-control`: Cache-Control header to be sent along GET/HEAD responses +- `auth`: + - `google_oauth2`: + - `enabled`: if true, the login endpoint will accept the token for authenticating with Google OAuth2 token. Note that you have to set "GOOGLE_CLIENT_ID" in your environment + to your API Key Client ID. ## 5. Prepare the database diff --git a/lib/schemas/login.json b/lib/schemas/login.json index 82a2094..68abe21 100644 --- a/lib/schemas/login.json +++ b/lib/schemas/login.json @@ -2,9 +2,10 @@ "$id": "login", "type": "object", "properties": { - "username" : { "type": "string", "maxLength": 50 }, - "password" : { "type": "string", "maxLength": 100 }, - "apikey": { "type": "string", "maxLength": 50 }, - "secret": { "type": "string", "maxLength": 100 } + "username" : { "type": "string", "maxLength": 50, "nullable": true }, + "password" : { "type": "string", "maxLength": 100, "nullable": true }, + "apikey": { "type": "string", "maxLength": 50, "nullable": true }, + "secret": { "type": "string", "maxLength": 100, "nullable": true }, + "googleoauth2token": { "type": "string", "maxLength": 10000, "nullable": true } } } \ No newline at end of file diff --git a/lib/schemas/system_config.json b/lib/schemas/system_config.json index 128c8bf..5ae9ac4 100644 --- a/lib/schemas/system_config.json +++ b/lib/schemas/system_config.json @@ -77,7 +77,19 @@ "readonly": { "type": "boolean" }, "enable_metrics": { "type": "boolean" }, "generated_password_length": { "type": "integer", "minimum": 10, "maximum": 50 }, - "cache-control": { "type": "string" } + "cache-control": { "type": "string" }, + "auth": { + "type": "object", + "properties": { + "google_oauth2": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + }, + "required": [ "enabled" ] + } + } + } }, "required": ["jwt_duration", "listen", "log", "https", "redis", "onetimetokens", "readonly", "crypto"] } \ No newline at end of file