New: Microsoft OAuth2.0

This commit is contained in:
Konstantin Hintermayer 2024-10-03 22:59:23 +02:00
parent 77974e2c61
commit 09c0747b97
7 changed files with 264 additions and 0 deletions

View File

@ -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");

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "firstName" TEXT;
ALTER TABLE "User" ADD COLUMN "lastName" TEXT;

View File

@ -30,8 +30,25 @@ model User {
lastName String? lastName String?
hashedPassword String? hashedPassword String?
salt String? salt String?
identites Identity[]
resetToken String? resetToken String?
resetTokenExpiresAt DateTime? resetTokenExpiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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)
}

View File

@ -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<unknown>

View File

@ -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 () => {
//
// })
})

View File

@ -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
}

View File

@ -10,6 +10,9 @@
port = 8910 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 apiUrl = "/.redwood/functions" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths
includeEnvironmentVariables = [ 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 # Add any ENV vars that should be available to the web side to this array
# See https://redwoodjs.com/docs/environment-variables#web # See https://redwoodjs.com/docs/environment-variables#web
] ]