Skip to content

Commit

Permalink
The new auth flow, toolbar, and workspace selector
Browse files Browse the repository at this point in the history
  • Loading branch information
AriaMinaei committed Dec 30, 2023
1 parent be366e5 commit 9f4e21f
Show file tree
Hide file tree
Showing 93 changed files with 2,974 additions and 1,367 deletions.
3 changes: 3 additions & 0 deletions examples/basic-dom/useDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export type UseDragOpts = {
onDrag: (dx: number, dy: number, event: MouseEvent) => void
}

/**
* @deprecated Deprecated in favor of `useChordial`
*/
export default function useDrag(
target: HTMLElement | undefined | null,
opts: UseDragOpts,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"jsonc-parser": "^3.1.0",
"lint-staged": "^13.0.3",
"node-gyp": "^9.1.0",
"prettier": "^3.0.2",
"prettier": "^3.1.1",
"sade": "^1.8.1",
"typescript": "5.1.6",
"yaml": "^2.3.1"
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"next": "latest",
"next-auth": "^4.23.2",
"npm-run-all": "^4.1.5",
"oauth4webapi": "^2.4.0",
"pg": "^8.11.2",
"prisma": "^4.12.0",
"react": "18.2.0",
Expand Down
30 changes: 30 additions & 0 deletions packages/app/prisma/migrations/20231127144216_/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the `LibAuthenticationFlow` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "DeviceAuthorizationFlowState" AS ENUM ('initialized', 'userAllowedAuth', 'userDeniedAuth', 'tokenAlreadyUsed');

-- DropTable
DROP TABLE "LibAuthenticationFlow";

-- DropEnum
DROP TYPE "LibAuthenticationFlowState";

-- CreateTable
CREATE TABLE "DeviceAuthorizationFlow" (
"deviceCode" TEXT NOT NULL,
"userCode" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL,
"lastCheckTime" TIMESTAMPTZ NOT NULL,
"nounce" TEXT NOT NULL,
"state" "DeviceAuthorizationFlowState" NOT NULL DEFAULT 'initialized',
"tokens" TEXT NOT NULL,

CONSTRAINT "DeviceAuthorizationFlow_pkey" PRIMARY KEY ("deviceCode")
);

-- CreateIndex
CREATE UNIQUE INDEX "DeviceAuthorizationFlow_userCode_key" ON "DeviceAuthorizationFlow"("userCode");
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "DeviceAuthorizationFlow" ADD COLUMN "codeChallenge" TEXT NOT NULL DEFAULT '',
ADD COLUMN "codeChallengeMethod" TEXT NOT NULL DEFAULT 'S256';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DeviceAuthorizationFlow" ADD COLUMN "scopes" JSONB NOT NULL DEFAULT '[]';
22 changes: 13 additions & 9 deletions packages/app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,21 @@ model VerificationToken {
@@unique([identifier, token])
}

model LibAuthenticationFlow {
preAuthenticationToken String @id // a random token generated by the server, and shared with the libray. This token is the key the library will use to obtain the final access/refersh tokens
userCode String @unique // a random, short token generated by the server. The user will then be redirected to [app]/auth/?userCode=[userCode]. At that URL, the user can log in via credentials, or if they're already logged in, they'll be asked whether to grant the library permission to edit projects
createdAt DateTime @db.Timestamptz() // the time the flow started. If older than a certain interval, the flow is considered expired/
lastCheckTime DateTime @db.Timestamptz() // the time the client last checked the status of this flow. If shorter than a certain interval, the client will be told to slow down.
clientFlowToken String // a random token generated by the client. It'll be returned once the final access/refresh tokens are generated, so the client can make sure the tokens belong to the authentication flow it initiated
state LibAuthenticationFlowState @default(initialized)
tokens String // will be non-empty if state=1. It'll contain a json object containing access/refresh tokens
// We're using OAuth2's device authorization flow to authorize `@theatre/studio` hosted in random origins.
model DeviceAuthorizationFlow {
deviceCode String @id // a random token generated by the server, and shared with the libray. This token is the key the library will use to obtain the final access/refersh tokens
userCode String @unique // a random, short token generated by the server. The user will then be redirected to [app]/auth/?userCode=[userCode]. At that URL, the user can log in via credentials, or if they're already logged in, they'll be asked whether to grant the library permission to edit projects
createdAt DateTime @db.Timestamptz() // the time the flow started. If older than a certain interval, the flow is considered expired/
lastCheckTime DateTime @db.Timestamptz() // the time the client last checked the status of this flow. If shorter than a certain interval, the client will be told to slow down.
nounce String // a random token generated by the client. It'll be included in the final idToken, so the client can make sure the tokens belong to the authentication flow it initiated
codeChallenge String @default("") // code_challenge as per https://tools.ietf.org/html/rfc7636
codeChallengeMethod String @default("S256") // code_challenge_method as per https://tools.ietf.org/html/rfc7636 (currently only "S256" is supported)
state DeviceAuthorizationFlowState @default(initialized)
tokens String // will be non-empty if state=1. It'll contain a json object containing access/refresh tokens
scopes Json @default("[]")
}

enum LibAuthenticationFlowState {
enum DeviceAuthorizationFlowState {
initialized
userAllowedAuth
userDeniedAuth
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/app/api/jwt-public-key/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {NextResponse} from 'next/server'
import {allowCors} from '~/utils'

async function handler(req: Request) {
if (req.method === 'OPTIONS') {
const res = new Response(null, {status: 204})
allowCors(res)

return res
}

const res = NextResponse.json({
publicKey: process.env.STUDIO_AUTH_JWT_PUBLIC_KEY,
})

allowCors(res)

return res
}

export {handler as GET, handler as OPTIONS}
131 changes: 131 additions & 0 deletions packages/app/src/app/api/studio-auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type {NextRequest} from 'next/server'
import {NextResponse} from 'next/server'
import prisma from 'src/prisma'

import {getAppSession, studioAuth} from 'src/utils/authUtils'
import {userCodeLength} from '~/server/studio-api/routes/studioAuthRouter'
import {studioAccessScopes} from '~/types'
import {type $IntentionalAny} from '@theatre/utils/types'

export const dynamic = 'force-dynamic'

async function libAuth(req: NextRequest) {
const userCode = req.nextUrl.searchParams.get('userCode')

if (typeof userCode !== 'string' || userCode.length !== userCodeLength) {
return NextResponse.json(
{
error: `userCode must be a string of length ${userCodeLength}`,
},
{status: 400},
)
}

const row = await prisma.deviceAuthorizationFlow.findFirst({
where: {
userCode,
},
})
if (row === null) {
return NextResponse.json(
{
error:
'This authentication flow either does not exist, or has already been used. Try again from the studio.',
},
{status: 404},
)
}

const session = await getAppSession()

// if no session, redirect to login
if (!session || !session.user) {
console.log('s', req.nextUrl.host, req.nextUrl.hostname, req.nextUrl.origin)
const redirectUrl = new URL(
`/api/auth/signin?callbackUrl=${encodeURIComponent(
req.nextUrl.toString(),
)}`,
req.nextUrl.origin,
)
return NextResponse.redirect(redirectUrl)
}

if (row.state === 'tokenAlreadyUsed') {
return NextResponse.json(
{
error:
'This authentication flow has already been used. Try again from the studio.',
},
{status: 400},
)
}

if (row.state === 'userDeniedAuth') {
return NextResponse.json(
{
error:
'This authentication flow has been denied by the user. Try again from the studio.',
},
{status: 400},
)
}

if (row.state === 'userAllowedAuth') {
return NextResponse.json(
{
error:
'This authentication flow has already been used. Try again from the studio.',
},
{status: 400},
)
}

if (row.state !== 'initialized') {
return NextResponse.json(
{
error: `This authentication flow is in an invalid state. Try again from the studio.`,
},
{status: 500},
)
}

const user = session.user
const nounce = row.nounce
const scopes = row.scopes

if (!studioAccessScopes.scopes.parse(scopes)) {
console.error(`bad scopes`, scopes)
await prisma.deviceAuthorizationFlow.delete({
where: {deviceCode: row.deviceCode},
})
return NextResponse.json(
{
error: `This authentication flow is in an invalid state. Try again from the studio.`,
},
{status: 500},
)
}

const {refreshToken, accessToken} = await studioAuth.createSession(
nounce,
user,
scopes as $IntentionalAny,
)

await prisma.deviceAuthorizationFlow.update({
where: {
deviceCode: row.deviceCode,
},
data: {
state: 'userAllowedAuth',
tokens: JSON.stringify({
accessToken,
refreshToken,
}),
},
})

return NextResponse.json('success', {status: 200})
}

export {libAuth as GET}
41 changes: 41 additions & 0 deletions packages/app/src/app/api/studio-trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {fetchRequestHandler} from '@trpc/server/adapters/fetch'
import type {NextRequest} from 'next/server'
import {createTRPCContext} from '~/server/api/trpc'
import {studioTrpcRouter} from '~/server/studio-api/root'
import {allowCors} from '~/utils'

// we don't need the trpc routes' responses to be cached
export const dynamic = 'force-dynamic'

const handler = async (req: NextRequest) => {
if (req.method === 'OPTIONS') {
const res = new Response(null, {
status: 204,
})
allowCors(res)
return res
}

const res = await fetchRequestHandler({
endpoint: '/api/studio-trpc',
req,
router: studioTrpcRouter,
createContext: () => createTRPCContext(),
onError:
process.env.NODE_ENV === 'development'
? ({path, error}) => {
console.error(
`❌ studio-trpc failed on ${path ?? '<no-path>'}: ${
error.message
}`,
)
}
: undefined,
})

allowCors(res)

return res
}

export {handler as GET, handler as POST, handler as OPTIONS}
19 changes: 5 additions & 14 deletions packages/app/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import {fetchRequestHandler} from '@trpc/server/adapters/fetch'
import {type NextRequest} from 'next/server'

import type {NextRequest} from 'next/server'
import {appRouter} from '~/server/api/root'
import {createTRPCContext} from '~/server/api/trpc'

const handler = async (req: NextRequest) => {
// Since these endpoints are for @theatre/studio as a library that can be used on any origin,
// we must allow CORS requests from any origin.
if (req.method === 'OPTIONS') {
return new Response()
}
// we don't need the trpc routes' responses to be cached
export const dynamic = 'force-dynamic'

const handler = async (req: NextRequest) => {
const res = await fetchRequestHandler({
endpoint: '/api/trpc',
req,
Expand All @@ -26,12 +22,7 @@ const handler = async (req: NextRequest) => {
: undefined,
})

res.headers.set('Access-Control-Allow-Origin', '*')
res.headers.set('Access-Control-Request-Method', '*')
res.headers.set('Access-Control-Allow-Methods', '*')
res.headers.set('Access-Control-Allow-Headers', '*')

return res
}

export {handler as GET, handler as POST}
export {handler as GET, handler as POST, handler as OPTIONS}
22 changes: 0 additions & 22 deletions packages/app/src/pages.bak/api/jwt-public-key.ts

This file was deleted.

Loading

0 comments on commit 9f4e21f

Please sign in to comment.