8000 Implement login with google by hakanshehu · Pull Request #57 · colanode/colanode · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Implement login with google #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 12, 2025
Merged
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
227 changes: 207 additions & 20 deletions apps/server/src/api/client/routes/accounts/google-login.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,132 @@
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import ky from 'ky';
import sharp from 'sharp';

import {
AccountStatus,
generateId,
GoogleUserInfo,
IdType,
ApiErrorCode,
apiErrorOutputSchema,
loginOutputSchema,
googleLoginInputSchema,
} from '@colanode/core';
import { database } from '@colanode/server/data/database';
import { buildLoginSuccessOutput } from '@colanode/server/lib/accounts';
import { UpdateAccount } from '@colanode/server/data/schema';
import { s3Client } from '@colanode/server/data/storage';
import {
buildLoginSuccessOutput,
buildLoginVerifyOutput,
} from '@colanode/server/lib/accounts';
import { config } from '@colanode/server/lib/config';
import { AccountAttributes } from '@colanode/server/types/accounts';

const GoogleUserInfoUrl = 'https://www.googleapis.com/oauth2/v1/userinfo';
const GoogleTokenUrl = 'https://oauth2.googleapis.com/token';

// While implementing this I was getting significant latencies from Google responses
// and I thought that was normal, therefore I set the timeout to 10 seconds.
// Later that day, I realized that Google was experiencing a large outage (https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW)
// and now I decided to keep it at 10 seconds as a memory.
const GoogleRequestTimeout = 1000 * 10;

interface GoogleTokenResponse {
access_token: string;
expires_in: number;
scope: string;
token_type: string;
id_token?: string;
refresh_token?: string;
}

interface GoogleUserResponse {
id: string;
email: string;
name: string;
verified_email: boolean;
picture?: string | null;
}

const fetchGoogleToken = async (
code: string,
clientId: string,
clientSecret: string
): Promise<GoogleTokenResponse | null> => {
try {
const params = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: 'postmessage',
grant_type: 'authorization_code',
});

const token = await ky
.post(GoogleTokenUrl, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
timeout: GoogleRequestTimeout,
})
.json<GoogleTokenResponse>();

return token;
} catch {
return null;
}
};

const fetchGoogleUser = async (
accessToken: string
): Promise<GoogleUserResponse | null> => {
try {
const user = await ky
.get(GoogleUserInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
timeout: GoogleRequestTimeout,
})
.json<GoogleUserResponse>();

return user;
} catch {
return null;
}
};

const uploadGooglePictureAsAvatar = async (
pictureUrl: string
): Promise<string | null> => {
try {
const arrayBuffer = await ky
.get(pictureUrl, { timeout: GoogleRequestTimeout })
.arrayBuffer();

const originalBuffer = Buffer.from(arrayBuffer);

const jpegBuffer = await sharp(originalBuffer)
.resize({ width: 500, height: 500, fit: 'inside' })
.jpeg()
.toBuffer();

const avatarId = generateId(IdType.Avatar);
const command = new PutObjectCommand({
Bucket: config.storage.bucket,
Key: `avatars/${avatarId}.jpeg`,
Body: jpegBuffer,
ContentType: 'image/jpeg',
});

await s3Client.send(command);

return avatarId;
} catch {
return null;
}
};

export const googleLoginRoute: FastifyPluginCallbackZod = (
instance,
Expand All @@ -34,60 +145,123 @@ export const googleLoginRoute: FastifyPluginCallbackZod = (
},
},
handler: async (request, reply) => {
if (!config.account.allowGoogleLogin) {
if (!config.account.google.enabled) {
return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed,
message: 'Google login is not allowed.',
});
}

const input = request.body;
const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`;
const response = await ky.get(url).json<GoogleUserInfo>();

if (!response) {
const token = await fetchGoogleToken(
input.code,
config.account.google.clientId,
config.account.google.clientSecret
);

if (!token?.access_token) {
return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed,
message: 'Google access token not found.',
});
}

const googleUser = await fetchGoogleUser(token.access_token);
if (!googleUser) {
return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed,
message: 'Failed to authenticate with Google.',
});
}

const existingAccount = await database
let existingAccount = await database
.selectFrom('accounts')
.where('email', '=', response.email)
.where('email', '=', googleUser.email)
.selectAll()
.executeTakeFirst();

if (existingAccount) {
if (existingAccount.status !== AccountStatus.Active) {
await database
const existingGoogleId = existingAccount.attributes?.googleId;
if (existingGoogleId && existingGoogleId !== googleUser.id) {
return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed,
message: 'Google account already exists.',
});
}

const updateAccount: UpdateAccount = {};

if (existingGoogleId !== googleUser.id) {
const newAttributes: AccountAttributes = {
...existingAccount.attributes,
googleId: googleUser.id,
};

updateAccount.attributes = JSON.stringify(newAttributes);
}

if (
existingAccount.status !== AccountStatus.Active &&
googleUser.verified_email
) {
updateAccount.status = AccountStatus.Active;
}

if (!existingAccount.avatar && googleUser.picture) {
updateAccount.avatar = await uploadGooglePictureAsAvatar(
googleUser.picture
);
}

if (Object.keys(updateAccount).length > 0) {
updateAccount.updated_at = new Date();
existingAccount = await database
.updateTable('accounts')
.set({
attrs: JSON.stringify({ googleId: response.id }),
updated_at: new Date(),
status: AccountStatus.Active,
})
.returningAll()
.set(updateAccount)
.where('id', '=', existingAccount.id)
.execute();
.executeTakeFirst();
}

if (!existingAccount) {
return reply.code(400).send({
code: ApiErrorCode.GoogleAuthFailed,
message: 'Google account not found.',
});
}

const output = await buildLoginSuccessOutput(
existingAccount,
request.client
);

return output;
}

let avatar: string | null = null;
if (googleUser.picture) {
avatar = await uploadGooglePictureAsAvatar(googleUser.picture);
}

let status = AccountStatus.Unverified;
if (googleUser.verified_email) {
status = AccountStatus.Active;
} else if (config.account.verificationType === 'automatic') {
status = AccountStatus.Active;
}

const newAccount = await database
.insertInto('accounts')
.values({
id: generateId(IdType.Account),
name: response.name,
email: response.email,
status: AccountStatus.Active,
name: googleUser.name,
email: googleUser.email,
avatar,
status,
created_at: new Date(),
password: null,
attrs: JSON.stringify({ googleId: response.id }),
attributes: JSON.stringify({ googleId: googleUser.id }),
})
.returningAll()
.executeTakeFirst();
Expand All @@ -99,6 +273,19 @@ export const googleLoginRoute: FastifyPluginCallbackZod = (
});
}

if (newAccount.status === AccountStatus.Unverified) {
if (config.account.verificationType === 'email') {
const output = await buildLoginVerifyOutput(newAccount);
return output;
}

return reply.code(400).send({
code: ApiErrorCode.AccountPendingVerification,
message:
'Account is not verified yet. Contact your administrator to verify your account.',
});
}

const output = await buildLoginSuccessOutput(newAccount, request.client);
return output;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const getOrCreateAccount = async (
name: getNameFromEmail(email),
email: email,
avatar: null,
attrs: null,
attributes: null,
password: null,
status: AccountStatus.Pending,
created_at: new Date(),
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export const configGetRoute: FastifyPluginCallbackZod = (instance, _, done) => {
sha: config.server.sha,
ip: request.client.ip,
pathPrefix: config.server.pathPrefix,
account: {
google: config.account.google.enabled
? {
enabled: config.account.google.enabled,
clientId: config.account.google.clientId,
}
: {
enabled: false,
},
},
};

return output;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Migration } from 'kysely';

// This migration is being done for the sake of consistency. We use the full name 'attributes' in other tables,
export const renameAccountAttributesColumn: Migration = {
up: async (db) => {
await db.schema
.alterTable('accounts')
.renameColumn('attrs', 'attributes')
.execute();
},
down: async (db) => {
await db.schema
.alterTable('accounts')
.renameColumn('attributes', 'attrs')
.execute();
},
};
2 changes: 2 additions & 0 deletions apps/server/src/data/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createVectorExtension } from './00017-create-vector-extension';
import { createNodeEmbeddingsTable } from './00018-create-node-embeddings-table';
import { createDocumentEmbeddingsTable } from './00019-create-document-embeddings-table';
import { alterDevicesPlatformColumn } from './00020-alter-devices-platform-column';
import { renameAccountAttributesColumn } from './00021-rename-account-attributes-column';

export const databaseMigrations: Record<string, Migration> = {
'00001_create_accounts_table': createAccountsTable,
Expand All @@ -42,4 +43,5 @@ export const databaseMigrations: Record<string, Migration> = {
'00018_create_node_embeddings_table': createNodeEmbeddingsTable,
'00019_create_document_embeddings_table': createDocumentEmbeddingsTable,
'00020_alter_devices_platform_column': alterDevicesPlatformColumn,
'00021_rename_account_attributes_column': renameAccountAttributesColumn,
};
7 changes: 6 additions & 1 deletion apps/server/src/data/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ import {
DocumentContent,
UpdateMergeMetadata,
} from '@colanode/core';
import { AccountAttributes } from '@colanode/server/types/accounts';

interface AccountTable {
id: ColumnType<string, string, never>;
name: ColumnType<string, string, string>;
email: ColumnType<string, string, never>;
avatar: ColumnType<string | null, string | null, string | null>;
password: ColumnType<string | null, string | null, string | null>;
attrs: ColumnType<string | null, string | null, string | null>;
attributes: JSONColumnType<
AccountAttributes | null,
string | null,
string | null
>;
created_at: ColumnType<Date, Date, never>;
updated_at: ColumnType<Date | null, Date | null, Date>;
status: ColumnType<number, number, number>;
Expand Down
Loading
0