From 09c0747b97e632d126dcda96ea814df5ea8663f2 Mon Sep 17 00:00:00 2001 From: KoCoder Date: Thu, 3 Oct 2024 22:59:23 +0200 Subject: [PATCH] New: Microsoft OAuth2.0 --- .../migration.sql | 39 +++++ .../migration.sql | 3 + api/db/schema.prisma | 17 ++ api/src/functions/oauth/oauth.scenarios.ts | 8 + api/src/functions/oauth/oauth.test.ts | 29 +++ api/src/functions/oauth/oauth.ts | 165 ++++++++++++++++++ redwood.toml | 3 + 7 files changed, 264 insertions(+) create mode 100644 api/db/migrations/20241003203156_create_oauth_identities/migration.sql create mode 100644 api/db/migrations/20241003204038_add_name_fields_to_user/migration.sql create mode 100644 api/src/functions/oauth/oauth.scenarios.ts create mode 100644 api/src/functions/oauth/oauth.test.ts create mode 100644 api/src/functions/oauth/oauth.ts diff --git a/api/db/migrations/20241003203156_create_oauth_identities/migration.sql b/api/db/migrations/20241003203156_create_oauth_identities/migration.sql new file mode 100644 index 0000000..4b8a2bd --- /dev/null +++ b/api/db/migrations/20241003203156_create_oauth_identities/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "Identity" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "provider" TEXT NOT NULL, + "uid" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "accessToken" TEXT, + "scope" TEXT, + "lastLoginAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Identity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "hashedPassword" TEXT, + "salt" TEXT, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("createdAt", "email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt", "updatedAt") SELECT "createdAt", "email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Identity_userId_idx" ON "Identity"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Identity_provider_uid_key" ON "Identity"("provider", "uid"); diff --git a/api/db/migrations/20241003204038_add_name_fields_to_user/migration.sql b/api/db/migrations/20241003204038_add_name_fields_to_user/migration.sql new file mode 100644 index 0000000..3e62483 --- /dev/null +++ b/api/db/migrations/20241003204038_add_name_fields_to_user/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "firstName" TEXT; +ALTER TABLE "User" ADD COLUMN "lastName" TEXT; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 29b3072..f0f0f1d 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -30,8 +30,25 @@ model User { lastName String? hashedPassword String? salt String? + identites Identity[] resetToken String? resetTokenExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Identity { + id Int @id @default(autoincrement()) + provider String + uid String + userId Int + user User @relation(fields: [userId], references: [id]) + accessToken String? + scope String? + lastLoginAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([provider, uid]) + @@index(userId) +} diff --git a/api/src/functions/oauth/oauth.scenarios.ts b/api/src/functions/oauth/oauth.scenarios.ts new file mode 100644 index 0000000..d24ff74 --- /dev/null +++ b/api/src/functions/oauth/oauth.scenarios.ts @@ -0,0 +1,8 @@ +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + // Define the "fixture" to write into your test database here + // See guide: https://redwoodjs.com/docs/testing#scenarios +}) + +export type StandardScenario = ScenarioData diff --git a/api/src/functions/oauth/oauth.test.ts b/api/src/functions/oauth/oauth.test.ts new file mode 100644 index 0000000..7a4c30e --- /dev/null +++ b/api/src/functions/oauth/oauth.test.ts @@ -0,0 +1,29 @@ +import { mockHttpEvent, mockContext } from '@redwoodjs/testing/api' + +import { handler } from './oauth' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-functions + +describe('oauth function', () => { + it('Should respond with 200', async () => { + const httpEvent = mockHttpEvent({ + queryStringParameters: { + id: '42', // Add parameters here + }, + }) + + const response = await handler(httpEvent, mockContext()) + const { data } = JSON.parse(response.body) + + expect(response.statusCode).toBe(200) + expect(data).toBe('oauth function') + }) + + // You can also use scenarios to test your api functions + // See guide here: https://redwoodjs.com/docs/testing#scenarios + // + // scenario('Scenario test', async () => { + // + // }) +}) diff --git a/api/src/functions/oauth/oauth.ts b/api/src/functions/oauth/oauth.ts new file mode 100644 index 0000000..2d59859 --- /dev/null +++ b/api/src/functions/oauth/oauth.ts @@ -0,0 +1,165 @@ +import type { APIGatewayEvent, Context } from 'aws-lambda' +import CryptoJS from 'crypto-js' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +/** + * The handler function is your code that processes http request events. + * You can use return and throw to send a response or error, respectively. + * + * Important: When deployed, a custom serverless function is an open API endpoint and + * is your responsibility to secure appropriately. + * + * @see {@link https://redwoodjs.com/docs/serverless-functions#security-considerations|Serverless Function Considerations} + * in the RedwoodJS documentation for more information. + * + * @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent + * @typedef { import('aws-lambda').Context } Context + * @param { APIGatewayEvent } event - an object which contains information from the invoker. + * @param { Context } _context - contains information about the invocation, + * function, and execution environment. + */ +export const handler = async (event: APIGatewayEvent, _context: Context) => { + logger.info(`${event.httpMethod} ${event.path}: oauth function`) + + switch (event.path) { + case '/oauth/microsoft/callback': + return await callback(event) + default: + return { + statusCode: 404, + } + } +} + +const callback = async (event: APIGatewayEvent) => { + const { code } = event.queryStringParameters + + console.log('Before fetch') + + const res = await fetch( + `https://login.microsoftonline.com/common/oauth2/v2.0/token`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded ', + }, + body: new URLSearchParams({ + client_id: process.env.MICROSOFT_OAUTH_CLIENT_ID, + code, + redirect_uri: process.env.MICROSOFT_OAUTH_REDIRECT_URI, + grant_type: 'authorization_code', + client_secret: process.env.MICROSOFT_OAUTH_CLIENT_SECRET, + }), + } + ) + + const body = await res.text() + const { access_token: accessToken, scope, error } = JSON.parse(body) + + if (error) { + logger.warn(error) + return { + statuscode: 400, + body: JSON.stringify({ body, error, code, scope }), + } + } + + try { + const providerUser = await getProviderUser(accessToken) + console.log(providerUser) + const user = await getUser({ providerUser, accessToken, scope }) + const cookie = secureCookie(user) + + return { + statusCode: 302, + headers: { + 'Set-Cookie': cookie, + Location: '/', + }, + } + } catch (e) { + return { statuscode: 500, body: e.message } + } + + // return { + // body: JSON.stringify({ accessToken, scope }), + // } +} + +const secureCookie = (user) => { + const expires = new Date() + expires.setFullYear(expires.getFullYear() + 1) + + const cookieAttrs = [ + `Expires=${expires.toUTCString()}`, + 'HttpOnly=true', + 'Path=/', + 'SameSite=Strict', + `Secure=${process.env.NODE_ENV !== 'development'}`, + ] + const data = JSON.stringify({ id: user.id }) + + const encrypted = CryptoJS.AES.encrypt( + data, + process.env.SESSION_SECRET + ).toString() + + return [`session=${encrypted}`, ...cookieAttrs].join('; ') +} + +const getUser = async ({ providerUser, accessToken, scope }) => { + const { user, identity } = await findOrCreateUser(providerUser) + + await db.identity.update({ + where: { id: identity.id }, + data: { accessToken, scope, lastLoginAt: new Date() }, + }) + + return user +} + +const findOrCreateUser = async (providerUser) => { + const identity = await db.identity.findFirst({ + where: { + provider: 'microsoft', + uid: providerUser.id.toString(), + }, + }) + + if (identity) { + const user = await db.user.findUnique({ where: { id: identity.userId } }) + return { user, identity } + } + + return await db.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + email: providerUser.mail, + firstName: providerUser.givenName, + lastName: providerUser.surname, + }, + }) + + const identity = await tx.identity.create({ + data: { + userId: user.id, + provider: 'microsoft', + uid: providerUser.id.toString(), + }, + }) + + return { user, identity } + }) +} + +const getProviderUser = async (token) => { + const response = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${token}` }, + }) + const body = JSON.parse(await response.text()) + + return body +} diff --git a/redwood.toml b/redwood.toml index 147631d..b3743fd 100644 --- a/redwood.toml +++ b/redwood.toml @@ -10,6 +10,9 @@ port = 8910 apiUrl = "/.redwood/functions" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths includeEnvironmentVariables = [ + "MICROSOFT_OAUTH_CLIENT_ID", + "MICROSOFT_OAUTH_SCOPES", + "MICROSOFT_OAUTH_REDIRECT_URI", # Add any ENV vars that should be available to the web side to this array # See https://redwoodjs.com/docs/environment-variables#web ]