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?
|
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)
|
||||||
|
}
|
||||||
|
|
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
|
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
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user