Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions backend/auth/google.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
const passport = require("passport");
const { Strategy: GoogleStrategy } = require("passport-google-oauth20");


function getFrontendOrigin(req) {
const proto = req.headers["x-forwarded-proto"] || req.protocol;
const host = req.headers["x-forwarded-host"] || req.get("host");
return `${proto}://${host}`;
}

console.log('google.ts')
function setUpGoogleAuth(app) {
console.log("[auth] Google routes registered");

async function fetchUserinfo(accessToken) {
if (!accessToken) return {};
try {
const resp = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return resp.ok ? await resp.json() : {};
} catch (err) {
console.log(`Error in fetchUserinfo: ${err}`);
return {};
}
}

passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback",
passReqToCallback: true //Enabled to receive id_token
},
async (req, access_token, refresh_token, params, profile, done) => {
// const id_token = params?.id_token; // <-- OIDC JWT
// return done(null, { provider: "Google" }, { accessToken, refreshToken, id_token, profile });
try {
let email = profile.emails && profile.emails.length ? profile.emails[0].value : null;
let fullName = profile.displayName || null;
let accessToken = access_token ?? null;
if (!email || !fullName) {
const user = await fetchUserinfo(accessToken);
email = email || user.email || null;
fullName = fullName || user?.name ||
[user?.given_name, user?.family_name].filter(Boolean).join(" ") || null;
}

const expires_in = params?.expires_in ?? null;
const expires_at = (expires_in != null) ? new Date(Date.now() + expires_in * 1000) : null;
console.log(`expires_in: ${expires_in}, expires_at: ${expires_at}`)
let data = {
provider: "Google",
provider_user_id: profile.id,
access_token: accessToken,
refresh_token: refresh_token ?? null,
id_token: params?.id_token ?? null,
token_response: { params, profile }
}
console.log(JSON.parse(JSON.stringify(data)))

let user = {
email,
fullName: fullName,
idToken: params?.id_token ?? null,
accessToken,
};
console.log("[GoogleStrategy] success user:", user);
return done(null, user);
} catch (err) {
return done(err);
}
}

));

app.get("/auth/google",
passport.authenticate("google", {
scope: ["profile", "email", "openid"], //'openid' ensures OIDC id_token
accessType: "offline", // request refresh_token
prompt: "consent", // Ensures refresh_token is returned
session: false, // Passport session not needed
state: true // CSRF protection (Passport can manage state)
})
);

app.get("/auth/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: "/login"
}),
async (req, res) => {
try {
console.log("[Callback handler] SUCCESS, req.user:", req.user);

const query = `
mutation($userEmail: String!) {
authenticateGoogle(input: { userEmail: $userEmail }) {
jwtToken {
role
personId
}
}
}
`;

console.log("email", req.user?.email);

const endpoint = process.env.GRAPHQL_ENDPOINT || "http://localhost:4000/graphql";
const payload = { query, variables: { userEmail: req.user?.email } };

console.log("GraphQL endpoint:", endpoint);
console.log("Request body:", JSON.stringify(payload));
console.log("Request cookies:", req.headers.cookie || "");
const pgResp = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: req.headers.cookie || "", // Pass cookies so LoginPlugin can attach new one
},
body: JSON.stringify(payload),
});

console.log("pgResp status:", pgResp.status, pgResp.statusText);

const text = await pgResp.text();
console.log("pgResp raw:", text);

let data;
try {
data = JSON.parse(text);
} catch (err) {
console.error("JSON parse error", err);
throw err;
}

// const data = await pgResp.json();
const jwtToken = data.data?.authenticateGoogle?.jwtToken;
console.log("pgResp jwtToken:", jwtToken);
if (!jwtToken) {
return res.redirect("/login?error=notfound");
}

console.log("[Google callback] JWT claims:", jwtToken);

//#region to be removed later
// if (!email) return res.redirect("/login");
// const email = req.user?.email;
// res.cookie("google_email", email, {
// httpOnly: true,
// secure: true,
// sameSite: "lax",
// maxAge: 5 * 60 * 1000, // 5 minutes
// });
// req.session.person_id = /* your person id */;
// req.session.role = /* your role */;
// console.log('frontend-origin', process.env.FRONTEND_ORIGIN)
// const FRONTEND = process.env.FRONTEND_ORIGIN || "http://localhost:3333";
// res.redirect(`${FRONTEND}/#/mealplans`);
//#endregion

const origin = getFrontendOrigin(req);
console.log('origin', origin)
const path = "/#/mealplans";
console.log(`${origin}${path}`);
res.redirect(`${origin}${path}`);

} catch (err) {
console.error("Error in Google callback:", err.stack);
return res.redirect("/login?error=server");
}
}
);

}

module.exports = { setUpGoogleAuth };
3 changes: 3 additions & 0 deletions backend/db_migrations/000021_social-login.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BEGIN;
DROP TABLE IF EXISTS app.social_login CASCADE;
COMMIT;
35 changes: 35 additions & 0 deletions backend/db_migrations/000021_social-login.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
BEGIN;

CREATE TABLE IF NOT EXISTS app.social_login (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL CHECK (provider IN ('Google', 'Facebook')),
provider_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
id_token TEXT NOT NULL,
token_response JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true, --social login deactivate
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL,
person_id BIGINT NOT NULL REFERENCES app.person (id) ON DELETE CASCADE,

-- Enforce uniqueness: One provider per person
CONSTRAINT unique_provider_per_person UNIQUE (person_id, provider),

-- Prevent duplicate provider_user_id across provider
CONSTRAINT unique_provider_user_id UNIQUE (provider, provider_user_id)
);

CREATE TRIGGER tg_social_login_set_updated_at BEFORE UPDATE
ON app.social_login
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();

CREATE TRIGGER tg_social_login_set_created_at BEFORE INSERT
ON app.social_login
FOR EACH ROW EXECUTE FUNCTION app.set_created_at();

GRANT SELECT, INSERT, UPDATE, DELETE on table app.social_login to app_user, app_meal_designer, app_admin;

GRANT USAGE, SELECT ON SEQUENCE app.social_login_id_seq TO app_user, app_meal_designer, app_admin;

COMMIT;
3 changes: 3 additions & 0 deletions backend/db_migrations/000022_session.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BEGIN;
DROP TABLE IF EXISTS app.session CASCADE;
COMMIT;
26 changes: 26 additions & 0 deletions backend/db_migrations/000022_session.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
BEGIN;

CREATE TABLE IF NOT EXISTS app.session (
id BIGSERIAL PRIMARY KEY,
auth_channel TEXT NOT NULL CHECK (auth_channel IN ('Password', 'Google', 'Facebook')),
timestamp TIMESTAMP NOT NULL DEFAULT now(),
person_id BIGINT NOT NULL REFERENCES app.person(id) ON DELETE CASCADE,
social_login_id BIGINT REFERENCES app.social_login(id) ON DELETE CASCADE
);

-- index to speed up lookups by person
CREATE INDEX IF NOT EXISTS idx_session_person_id ON app.session(person_id);

CREATE TRIGGER tg_session_set_updated_at BEFORE UPDATE
ON app.session
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();

CREATE TRIGGER tg_session_set_created_at BEFORE INSERT
ON app.session
FOR EACH ROW EXECUTE FUNCTION app.set_created_at();

GRANT SELECT, INSERT, UPDATE, DELETE on table app.session to app_user, app_meal_designer, app_admin;

GRANT USAGE, SELECT ON SEQUENCE app.session_id_seq TO app_user, app_meal_designer, app_admin;

COMMIT;
6 changes: 6 additions & 0 deletions backend/db_migrations/000023_add-status-to-person.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE app.person DROP COLUMN IF EXISTS status;
ALTER TYPE app.current_user DROP ATTRIBUTE IF EXISTS status;
DROP TYPE IF EXISTS app.status_type;
DROP FUNCTION IF EXISTS app.current_person();
COMMIT;
25 changes: 25 additions & 0 deletions backend/db_migrations/000023_add-status-to-person.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Create the enum type: app.status_type
DO $$ BEGIN
CREATE TYPE app.status_type AS ENUM ('app_pending', 'app_active', 'app_inactive');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

-- Add the new column using the enum
ALTER TABLE app.person ADD COLUMN status app.status_type NOT NULL DEFAULT 'app_pending';

ALTER TYPE app.current_user ADD ATTRIBUTE status TEXT;

-- Add status to the function: app.current_person()
CREATE OR REPLACE FUNCTION app.current_person() RETURNS app.current_user AS $$
SELECT
app.person.id,
app.person.role::text,
app.person.email,
app.person.full_name,
app.person.slug,
app.person.terms_and_conditions,
app.person.status::text
FROM app.person
WHERE id = nullif(current_setting('jwt.claims.person_id', true), '')::bigint
$$ LANGUAGE sql STABLE SECURITY DEFINER;
5 changes: 5 additions & 0 deletions backend/db_migrations/000024_add-authenticate-google.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
begin;

DROP FUNCTION IF EXISTS app.authenticate_google (text);

commit;
29 changes: 29 additions & 0 deletions backend/db_migrations/000024_add-authenticate-google.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

-- Authenticate Google login by email.
-- If the email exists in the person table, issue a JWT with role and person_id;
-- otherwise return null.

create or replace function app.authenticate_google(user_email text)
returns app.jwt_token as $$
declare
person app.person;
begin
-- look up person by email
select * into person
from app.person p
where p.email = user_email;

if person is null then
return null; -- email not found in DB
end if;

-- issue jwt_token: (role, person_id, exp)
return (
person.role::text,
person.id,
extract(epoch from (now() + interval '7 days'))
)::app.jwt_token;
end;
$$ language plpgsql security definer;
comment on function app.authenticate_google(text) is 'Authenticate Google login by email. If email exists in the person table, issue a JWT with claims for Person and role; otherwise return null.';
grant execute on function app.authenticate_google(text) to app_anonymous, app_user;
14 changes: 8 additions & 6 deletions backend/hooks/login_plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,26 @@ const useAuthCredentials = (build) => {
if(!isRootMutation) {
return null;
}
if(!pgFieldIntrospection || pgFieldIntrospection.name !== 'authenticate') {
return null;
}
const authMutations = ["authenticate", "authenticateGoogle"];
if (!pgFieldIntrospection || !authMutations.includes(pgFieldIntrospection.name)) {
return null;
}
// explaining the double negative. If pgFieldIntrospection is not null and has
// the name 'authenticate' only then we need to run the following.
console.log('ready to setup hook...');
console.log(`ready to setup hook for ${pgFieldIntrospection.name}`);
return {
before: [],
after: [{
priority: 100,
callback: (result, args, context, resolvInfo) => {
console.log('hook triggered', result);
if(result.data == null) {
console.log(`hook triggered for ${pgFieldIntrospection.name} ${result}`);
if(result.data == null || result.data['@jwtToken'] == null) {
resolvInfo.graphileMeta.messages.push({
level: "error",
message: "invalid credentials"
});
} else {
console.log("LoginPlugin setting cookie for:", result.data['@jwtToken']);
context.setAuthCookie(
result.data['@jwtToken'].personId,
result.data['@jwtToken'].role);
Expand Down
Loading