New: Microsoft OAuth2.0
This commit is contained in:
parent
77974e2c61
commit
09c0747b97
|
@ -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");
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "firstName" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN "lastName" TEXT;
|
|
@ -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)
|
||||
}
|
||||
|
|
8
api/src/functions/oauth/oauth.scenarios.ts
Normal file
8
api/src/functions/oauth/oauth.scenarios.ts
Normal 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>
|
29
api/src/functions/oauth/oauth.test.ts
Normal file
29
api/src/functions/oauth/oauth.test.ts
Normal 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 () => {
|
||||
//
|
||||
// })
|
||||
})
|
165
api/src/functions/oauth/oauth.ts
Normal file
165
api/src/functions/oauth/oauth.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue
Block a user