diff --git a/examples/realworld/main.wasp b/examples/realworld/main.wasp index 53e4731bc9..38015ef404 100644 --- a/examples/realworld/main.wasp +++ b/examples/realworld/main.wasp @@ -7,7 +7,9 @@ app Conduit { auth: { userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + }, onAuthFailedRedirectTo: "/login" }, diff --git a/examples/thoughts/main.wasp b/examples/thoughts/main.wasp index 860306989a..3aff240fb6 100644 --- a/examples/thoughts/main.wasp +++ b/examples/thoughts/main.wasp @@ -3,7 +3,9 @@ app Thoughts { db: { system: PostgreSQL }, auth: { userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + }, onAuthFailedRedirectTo: "/login" }, dependencies: [ diff --git a/examples/tutorials/TodoApp/main.wasp b/examples/tutorials/TodoApp/main.wasp index 8db857330a..4b38e3f568 100644 --- a/examples/tutorials/TodoApp/main.wasp +++ b/examples/tutorials/TodoApp/main.wasp @@ -3,7 +3,9 @@ app TodoApp { auth: { userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + }, onAuthFailedRedirectTo: "/login" }, diff --git a/examples/waspello/main.wasp b/examples/waspello/main.wasp index f09802140e..46e82e9077 100644 --- a/examples/waspello/main.wasp +++ b/examples/waspello/main.wasp @@ -7,7 +7,9 @@ app trello { auth: { userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + }, onAuthFailedRedirectTo: "/login" }, diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index caba4f8d6f..7dbdb082a6 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -3,7 +3,7 @@ ## v0.6.0.0 (TBD) ### BREAKING CHANGES -- The `EmailAndPassword` auth method has been renamed `UsernameAndPassword` to better reflect the current usage. Email validation will be addressed in the future. +- The `EmailAndPassword` auth method has been renamed `usernameAndPassword` to better reflect the current usage. Email validation will be addressed in the future. - This means the `auth.userEntity` model should now have field called `username` (instead of `email`, as before). - If you'd like to treat the old `email` field as `username`, you can create a migration file like so: ```bash @@ -24,8 +24,16 @@ ``` - NOTE: If you simply changed `email` to `username` in your .wasp file, Prisma will try to drop the table and recreate it, which is likely not what you want if you have data you want to preserve. - If you would like to add a new `username` column and keep `email` as is, be sure to add a calculated value in the migration (perhaps a random string, or something based on the `email`). The `username` column should remain `NOT NULL` and `UNIQUE`. +- `WASP_WEB_CLIENT_URL` is now a required environment variable to improve CORS security. It is set by default in development. In production, this should point to the URL where your frontend app is being hosted. +- The generated Dockerfile has been updated from `node:14-alpine` to `node:16-alpine`. - Wasp Jobs callback function arguments have been updated to the following: `async function jobHandler(args, context)`. Jobs can now make use of entities, accessed via `context`, like Operations. Additionally, the data passed into the Job handler function are no longer wrapped in a `data` property, and are now instead accessed exactly as they are supplied via `args`. +### [NEW FEATURE] Google is now a supported authentication method! + +You can now offer your users the ability to sign in with Google! Enabling it is just a few lines and offers a fast, easy, and secure way to get users into your app! We also have a comprehensive setup guide for creating a new app in the Google Developer Console. + +Stay tuned, as more external auth methods will be added in the future. Let us know what you'd like to see support for next! + --- ## v0.5.2.1 (2022/07/14) diff --git a/waspc/data/Generator/templates/Dockerfile b/waspc/data/Generator/templates/Dockerfile index 3bc727d1a3..631bb68c0b 100644 --- a/waspc/data/Generator/templates/Dockerfile +++ b/waspc/data/Generator/templates/Dockerfile @@ -1,5 +1,5 @@ {{={= =}=}} -FROM node:14-alpine AS node +FROM node:{= nodeMajorVersion =}-alpine AS node FROM node AS base @@ -11,6 +11,9 @@ FROM base AS server-builder RUN apk add --no-cache build-base libtool autoconf automake python3 WORKDIR /app # Install npm packages, resulting in node_modules/. +{=# usingServerPatches =} +COPY server/patches ./server/patches +{=/ usingServerPatches =} COPY server/package*.json ./server/ RUN cd server && npm install {=# usingPrisma =} diff --git a/waspc/data/Generator/templates/react-app/public/images/btn_google_signin_dark_normal_web@2x.png b/waspc/data/Generator/templates/react-app/public/images/btn_google_signin_dark_normal_web@2x.png new file mode 100644 index 0000000000..f27bb24330 Binary files /dev/null and b/waspc/data/Generator/templates/react-app/public/images/btn_google_signin_dark_normal_web@2x.png differ diff --git a/waspc/data/Generator/templates/react-app/src/auth/buttons/Google.js b/waspc/data/Generator/templates/react-app/src/auth/buttons/Google.js new file mode 100644 index 0000000000..d0d907ad97 --- /dev/null +++ b/waspc/data/Generator/templates/react-app/src/auth/buttons/Google.js @@ -0,0 +1,11 @@ +import config from '../../config.js' + +export const googleSignInUrl = `${config.apiUrl}/auth/external/google/login` + +export function GoogleSignInButton(props) { + return ( + + Sign in with Google + + ) +} diff --git a/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.js b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.js new file mode 100644 index 0000000000..e5137cb027 --- /dev/null +++ b/waspc/data/Generator/templates/react-app/src/auth/pages/OAuthCodeExchange.js @@ -0,0 +1,56 @@ +{{={= =}=}} +import React, { useEffect } from 'react' +import { useHistory } from 'react-router-dom' + +import config from '../../config.js' +import api, { setAuthToken } from '../../api.js' + +// After a user authenticates via an Oauth 2.0 provider, this is the page that +// the provider should redirect them to, while providing query string parameters +// that contain information needed for the API server to authenticate with the provider. +// This page forwards that information to the API server and in response get a JWT, +// which it stores on the client, therefore completing the OAuth authentication process. +export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRedirect }) { + const history = useHistory() + + useEffect(() => { + // NOTE: Different auth methods will have different Wasp API server validation paths. + // This helps us reuse one component for various methods (e.g., Google, Facebook, etc.). + const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect) + + exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) + }, [history, pathToApiServerRouteHandlingOauthRedirect]) + + return ( +

Completing login process...

+ ) +} + +function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect) { + // Take the redirect query params supplied by the external OAuth provider and + // send them as-is to our backend, so Passport can finish the process. + const queryParams = window.location.search + return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}` +} + +async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) { + const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect) + + if (token !== null) { + setAuthToken(token) + history.push('{= onAuthSucceededRedirectTo =}') + } else { + console.error('Error obtaining JWT token') + history.push('{= onAuthFailedRedirectTo =}') + } +} + +async function exchangeCodeForJwt(url) { + try { + const response = await api.get(url) + return response?.data?.token || null + } catch (e) { + console.error(e) + return null + } +} diff --git a/waspc/data/Generator/templates/react-app/src/auth/useAuth.js b/waspc/data/Generator/templates/react-app/src/auth/useAuth.js index bdc82ff56d..1def2c5990 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/useAuth.js +++ b/waspc/data/Generator/templates/react-app/src/auth/useAuth.js @@ -11,7 +11,7 @@ async function getMe() { return response.data } catch (error) { - if (error.response?.status === 403) { + if (error.response?.status === 401) { return null } else { handleApiError(error) diff --git a/waspc/data/Generator/templates/react-app/src/router.js b/waspc/data/Generator/templates/react-app/src/router.js index 42ed9664c0..56f9f53d91 100644 --- a/waspc/data/Generator/templates/react-app/src/router.js +++ b/waspc/data/Generator/templates/react-app/src/router.js @@ -10,6 +10,9 @@ import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage.js" import {= importWhat =} from "{= importFrom =}" {=/ pagesToImport =} +{=# isExternalAuthEnabled =} +import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange" +{=/ isExternalAuthEnabled =} const router = ( @@ -17,6 +20,16 @@ const router = ( {=# routes =} {=/ routes =} + + {=# isExternalAuthEnabled =} + + {=# isGoogleAuthEnabled =} + + + + {=/ isGoogleAuthEnabled =} + + {=/ isExternalAuthEnabled =} ) diff --git a/waspc/data/Generator/templates/server/package.json b/waspc/data/Generator/templates/server/package.json index 4d4754db80..5e92a13bd1 100644 --- a/waspc/data/Generator/templates/server/package.json +++ b/waspc/data/Generator/templates/server/package.json @@ -10,7 +10,8 @@ "db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma", "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma", "start-production": "{=& startProductionScript =}", - "standard": "standard" + "standard": "standard", + "postinstall": "patch-package" }, "nodemonConfig": { "delay": "1000" diff --git a/waspc/data/Generator/templates/server/patches/oauth+0.9.15.patch b/waspc/data/Generator/templates/server/patches/oauth+0.9.15.patch new file mode 100644 index 0000000000..a797ef0353 --- /dev/null +++ b/waspc/data/Generator/templates/server/patches/oauth+0.9.15.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/oauth/lib/oauth2.js b/node_modules/oauth/lib/oauth2.js +index 77241c4..b6c0184 100644 +--- a/node_modules/oauth/lib/oauth2.js ++++ b/node_modules/oauth/lib/oauth2.js +@@ -158,8 +158,14 @@ exports.OAuth2.prototype._executeRequest= function( http_library, options, post_ + }); + }); + request.on('error', function(e) { +- callbackCalled= true; +- callback(e); ++ // Ref: https://github.com/ciaranj/node-oauth/pull/363 ++ // `www.googleapis.com` does `ECONNRESET` just after data is received in `passBackControl` ++ // this prevents the callback from being called twice, first in passBackControl and second time in here ++ // see also NodeJS Stream documentation: "The 'error' event may be emitted by a Readable implementation at any time" ++ if(!callbackCalled) { ++ callbackCalled= true; ++ callback(e); ++ } + }); + + if( (options.method == 'POST' || options.method == 'PUT') && post_body ) { diff --git a/waspc/data/Generator/templates/server/src/app.js b/waspc/data/Generator/templates/server/src/app.js index 6c769e0cd1..7ffcd317d9 100644 --- a/waspc/data/Generator/templates/server/src/app.js +++ b/waspc/data/Generator/templates/server/src/app.js @@ -6,6 +6,7 @@ import helmet from 'helmet' import HttpError from './core/HttpError.js' import indexRouter from './routes/index.js' +import config from './config.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that // it can be used in unit tests to test each route individually. @@ -13,7 +14,10 @@ import indexRouter from './routes/index.js' const app = express() app.use(helmet()) -app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests. +app.use(cors({ + // TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup. + origin: config.frontendUrl, +})) app.use(logger('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) diff --git a/waspc/data/Generator/templates/server/src/config.js b/waspc/data/Generator/templates/server/src/config.js index 5d3c2ada35..4090e9309d 100644 --- a/waspc/data/Generator/templates/server/src/config.js +++ b/waspc/data/Generator/templates/server/src/config.js @@ -13,6 +13,7 @@ const config = { env, port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, + frontendUrl: undefined, {=# isAuthEnabled =} auth: { jwtSecret: undefined @@ -20,6 +21,7 @@ const config = { {=/ isAuthEnabled =} }, development: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000', {=# isAuthEnabled =} auth: { jwtSecret: 'DEVJWTSECRET' @@ -27,6 +29,7 @@ const config = { {=/ isAuthEnabled =} }, production: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL, {=# isAuthEnabled =} auth: { jwtSecret: process.env.JWT_SECRET diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index 7cefdb6d21..bb70d47bf1 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken' import SecurePassword from 'secure-password' import util from 'util' +import { randomInt } from 'node:crypto' import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' @@ -69,5 +70,52 @@ export const verifyPassword = async (hashedPassword, password) => { } } -export default auth +// Generates an unused username that looks similar to "quick-purple-sheep-91231". +// It generates several options and ensures it picks one that is not currently in use. +export function generateAvailableDictionaryUsername() { + const adjectives = ['fuzzy', 'tall', 'short', 'nice', 'happy', 'quick', 'slow', 'good', 'new', 'old', 'first', 'last', 'old', 'young'] + const colors = ['red', 'green', 'blue', 'white', 'black', 'brown', 'purple', 'orange', 'yellow'] + const nouns = ['wasp', 'cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep'] + + const potentialUsernames = [] + for (let i = 0; i < 10; i++) { + const potentialUsername = `${adjectives[randomInt(adjectives.length)]}-${colors[randomInt(colors.length)]}-${nouns[randomInt(nouns.length)]}-${randomInt(100_000)}` + potentialUsernames.push(potentialUsername) + } + + return findAvailableUsername(potentialUsernames) +} + +// Generates an unused username based on an array of username segments and a separator. +// It generates several options and ensures it picks one that is not currently in use. +export function generateAvailableUsername(usernameSegments, config) { + const separator = config?.separator || '-' + const baseUsername = usernameSegments.join(separator) + + const potentialUsernames = [] + for (let i = 0; i < 10; i++) { + const potentialUsername = `${baseUsername}${separator}${randomInt(100_000)}` + potentialUsernames.push(potentialUsername) + } + + return findAvailableUsername(potentialUsernames) +} + +// Checks the database for an unused username from an array provided and returns first. +async function findAvailableUsername(potentialUsernames) { + const users = await prisma.{= userEntityLower =}.findMany({ + where: { + username: { in: potentialUsernames }, + } + }) + const takenUsernames = users.map(user => user.username) + const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username)) + + if (availableUsernames.length === 0) { + throw new Error('Unable to generate a unique username. Please contact Wasp.') + } + + return availableUsernames[0] +} +export default auth diff --git a/waspc/data/Generator/templates/server/src/routes/auth/index.js b/waspc/data/Generator/templates/server/src/routes/auth/index.js index 39bf2e0fb4..55436b476f 100644 --- a/waspc/data/Generator/templates/server/src/routes/auth/index.js +++ b/waspc/data/Generator/templates/server/src/routes/auth/index.js @@ -1,3 +1,4 @@ +{{={= =}=}} import express from 'express' import auth from '../../core/auth.js' @@ -5,11 +6,18 @@ import login from './login.js' import signup from './signup.js' import me from './me.js' +{=# isExternalAuthEnabled =} +import passportAuth from './passport/passport.js' +{=/ isExternalAuthEnabled =} + const router = express.Router() router.post('/login', login) router.post('/signup', signup) router.get('/me', auth, me) -export default router +{=# isExternalAuthEnabled =} +router.use('/external', passportAuth) +{=/ isExternalAuthEnabled =} +export default router diff --git a/waspc/data/Generator/templates/server/src/routes/auth/me.js b/waspc/data/Generator/templates/server/src/routes/auth/me.js index 3bdd2542ba..0e1346c055 100644 --- a/waspc/data/Generator/templates/server/src/routes/auth/me.js +++ b/waspc/data/Generator/templates/server/src/routes/auth/me.js @@ -5,6 +5,6 @@ export default handleRejection(async (req, res) => { if (req.{= userEntityLower =}) { return res.json(req.{= userEntityLower =}) } else { - return res.status(403).send() + return res.status(401).send() } }) diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/google.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/google.js new file mode 100644 index 0000000000..726fc013b0 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/google.js @@ -0,0 +1,77 @@ +import express from 'express' +import passport from 'passport' +import GoogleStrategy from 'passport-google-oauth20' + +import waspServerConfig from '../../../../config.js' +import { contextWithUserEntity, authConfig, findOrCreateUserByExternalAuthAssociation } from '../../utils.js' +import { sign } from '../../../../core/auth.js' +import { configFn, getUserFieldsFn } from './googleConfig.js' + +// Validates the provided config function returns all required data. +const config = ((config) => { + if (!config?.clientId) { + throw new Error("auth.google.configFn must return an object with a clientId property.") + } + + if (!config?.clientSecret) { + throw new Error("auth.google.configFn must return an object with a clientSecret property.") + } + + if (!config?.scope) { + throw new Error("auth.google.configFn must return an object with a scope property.") + } else if (!Array.isArray(config.scope) || !config.scope.includes('profile')) { + throw new Error("auth.google.configFn returned an object with an invalid scope property. It must be an array including 'profile'.") + } + + return config +})(await configFn()) + +passport.use('waspGoogleLoginStrategy', new GoogleStrategy({ + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${waspServerConfig.frontendUrl}/auth/login/google`, + scope: config.scope, + passReqToCallback: true +}, addGoogleProfileToRequest)) + +// This function is invoked after we successfully exchange the one-time-use OAuth code for a real Google API token. +// This token was used to get the Google profile information supplied as a parameter. +// We add the Google profile to the request for downstream use. +async function addGoogleProfileToRequest(req, _accessToken, _refreshToken, googleProfile, done) { + req.wasp = { ...req.wasp, googleProfile } + + done(null, {}) +} + +const router = express.Router() + +// Constructs a Google OAuth URL and redirects browser to start sign in flow. +router.get('/login', passport.authenticate('waspGoogleLoginStrategy', { session: false })) + +// Validates the OAuth code from the frontend, via server-to-server communication +// with Google. If valid, provides frontend a response containing the JWT. +// NOTE: `addGoogleProfileToRequest` is invoked as part of the `passport.authenticate` +// call, before the final route handler callback. This is how we gain access to `req.wasp.googleProfile`. +router.get('/validateCodeForLogin', + passport.authenticate('waspGoogleLoginStrategy', { + session: false, + failureRedirect: waspServerConfig.frontendUrl + authConfig.failureRedirectPath + }), + async function (req, res) { + const googleProfile = req?.wasp?.googleProfile + + if (!googleProfile) { + throw new Error('Missing Google profile on request. This should not happen! Please contact Wasp.') + } else if (!googleProfile.id) { + throw new Error("Google profile was missing required id property. This should not happen! Please contact Wasp.") + } + + // Wrap call to getUserFieldsFn so we can invoke only if needed. + const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: googleProfile }) + const user = await findOrCreateUserByExternalAuthAssociation('google', googleProfile.id, getUserFields) + + const token = await sign(user.id) + res.json({ token }) + }) + +export default router diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleConfig.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleConfig.js new file mode 100644 index 0000000000..0b2d2c9c7b --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleConfig.js @@ -0,0 +1,17 @@ +{{={= =}=}} + +{=# doesConfigFnExist =} +{=& configFnImportStatement =} +export { {= configFnIdentifier =} as configFn } +{=/ doesConfigFnExist =} +{=^ doesConfigFnExist =} +export { configFn } from './googleDefaults.js' +{=/ doesConfigFnExist =} + +{=# doesOnSignInFnExist =} +{=& getUserFieldsFnImportStatement =} +export { {= getUserFieldsFnIdentifier =} as getUserFieldsFn } +{=/ doesOnSignInFnExist =} +{=^ doesOnSignInFnExist =} +export { getUserFieldsFn } from './googleDefaults.js' +{=/ doesOnSignInFnExist =} diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleDefaults.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleDefaults.js new file mode 100644 index 0000000000..09db94b871 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleDefaults.js @@ -0,0 +1,23 @@ +import { generateAvailableDictionaryUsername } from '../../../../core/auth.js' + +// Default implementation if there is no `auth.methods.google.configFn`. +export function configFn() { + const clientId = process.env['GOOGLE_CLIENT_ID'] + const clientSecret = process.env['GOOGLE_CLIENT_SECRET'] + + if (!clientId) { + throw new Error("Missing GOOGLE_CLIENT_ID environment variable.") + } + + if (!clientSecret) { + throw new Error("Missing GOOGLE_CLIENT_SECRET environment variable.") + } + + return { clientId, clientSecret, scope: ['profile'] } +} + +// Default implementation if there is no `auth.methods.google.getUserFieldsFn`. +export async function getUserFieldsFn(_context, _args) { + const username = await generateAvailableDictionaryUsername() + return { username } +} diff --git a/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js b/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js new file mode 100644 index 0000000000..fe8f820ee0 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/passport/passport.js @@ -0,0 +1,14 @@ +{{={= =}=}} +import express from 'express' + +{=# isGoogleAuthEnabled =} +import googleAuth from './google/google.js' +{=/ isGoogleAuthEnabled =} + +const router = express.Router() + +{=# isGoogleAuthEnabled =} +router.use('/google', googleAuth) +{=/ isGoogleAuthEnabled =} + +export default router diff --git a/waspc/data/Generator/templates/server/src/routes/auth/utils.js b/waspc/data/Generator/templates/server/src/routes/auth/utils.js new file mode 100644 index 0000000000..ffa9fe77f2 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/routes/auth/utils.js @@ -0,0 +1,42 @@ +{{={= =}=}} + +import { v4 as uuidv4 } from 'uuid' + +import prisma from '../../dbClient.js' + +export const contextWithUserEntity = { + entities: { + {= userEntityUpper =}: prisma.{= userEntityLower =} + } +} + +export const authConfig = { + failureRedirectPath: "{= failureRedirectPath =}", + successRedirectPath: "{= successRedirectPath =}", +} + +export async function findOrCreateUserByExternalAuthAssociation(provider, providerId, getUserFields) { + // Attempt to find a User by an external auth association. + const externalAuthAssociation = await prisma.{= externalAuthEntityLower =}.findFirst({ + where: { provider, providerId }, + include: { user: true } + }) + + if (externalAuthAssociation) { + return externalAuthAssociation.user + } + + // No external auth association linkage found. Create a new User using details from + // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. + // NOTE: For now, we force a random (uuidv4) password string. In the future, we will allow password reset. + const userFields = await getUserFields() + const userAndExternalAuthAssociation = { + ...userFields, + password: uuidv4(), + externalAuthAssociations: { + create: [{ provider, providerId }] + } + } + + return await prisma.{= userEntityLower =}.create({ data: userAndExternalAuthAssociation }) +} diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index b441e4639f..a4a1b48c00 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -11,7 +11,7 @@ "file", "Dockerfile" ], - "93bb104c26d9c0e06b947bd9b2be153e579f599590d3fa8876db2ca777ce5183" + "faae4a6f87557e624d9c7631ec6f3ed20e31115f2c177bfe19b3f52d163d86e9" ], [ [ @@ -46,21 +46,21 @@ "file", "server/package.json" ], - "9c3186abd79e11706fd56699054b653303dbae26b3b7037a2104377d697b7b22" + "f737ca2e276090fdbc46be2279b41d50973c024bb31259a45206a6cce7c469e9" ], [ [ "file", "server/src/app.js" ], - "31bf8e67e2bc95b2558c649fdb51e6c7a6d373cbc8488141ab8652e8d69604ce" + "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72" ], [ [ "file", "server/src/config.js" ], - "beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50" + "d7f23a370f15869e6a3920552993c41d5e1a23d8e12a4a9d3a583d22b730a5d6" ], [ [ @@ -207,7 +207,7 @@ "file", "web-app/package.json" ], - "bf1d166d30b2b04b50180276c8c34d7cae29e31ce11635e0199e2fde537e46ce" + "9aaa97208e8b524bf6c15fe9004e99564bfd0e8a3942e2ba7156795634b0f0bb" ], [ [ @@ -333,7 +333,7 @@ "file", "web-app/src/router.js" ], - "959a870fd08b7bc2eabf397750256946e26960e08fb2887ec65ab5fc775aebfc" + "dd6f5f5c6981df1935c02437d7a6ae343841300c3f7e99b0ad6ca62e95071421" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/Dockerfile b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/Dockerfile index ec86950447..6711e7a7b8 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/Dockerfile +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine AS node +FROM node:16-alpine AS node FROM node AS base diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json index ba3f0505a0..435173c51e 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^8.3.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json index 7666c12c9e..73fe45996f 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/package.json @@ -9,7 +9,9 @@ "helmet": "^4.6.0", "jsonwebtoken": "^8.5.1", "morgan": "~1.9.1", - "secure-password": "^4.0.0" + "patch-package": "^6.4.7", + "secure-password": "^4.0.0", + "uuid": "^8.3.2" }, "devDependencies": { "nodemon": "^2.0.4", @@ -30,6 +32,7 @@ "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma", "db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma", "debug": "DEBUG=server:* npm start", + "postinstall": "patch-package", "standard": "standard", "start": "nodemon -r dotenv/config ./src/server.js", "start-production": "NODE_ENV=production node ./src/server.js" diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js index 6c769e0cd1..7ffcd317d9 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/app.js @@ -6,6 +6,7 @@ import helmet from 'helmet' import HttpError from './core/HttpError.js' import indexRouter from './routes/index.js' +import config from './config.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that // it can be used in unit tests to test each route individually. @@ -13,7 +14,10 @@ import indexRouter from './routes/index.js' const app = express() app.use(helmet()) -app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests. +app.use(cors({ + // TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup. + origin: config.frontendUrl, +})) app.use(logger('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js index a7958f3a3f..2769066bf7 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/config.js @@ -12,10 +12,13 @@ const config = { env, port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, + frontendUrl: undefined, }, development: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000', }, production: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL, } } diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json index 4b45bbb759..de402cc5ce 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/package.json @@ -19,8 +19,7 @@ "react-error-overlay": "6.0.9", "react-query": "^3.34.19", "react-router-dom": "^5.1.2", - "react-scripts": "4.0.3", - "uuid": "^3.4.0" + "react-scripts": "4.0.3" }, "devDependencies": {}, "engineStrict": true, diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.js index 5516a073dd..c783ed20ac 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.js +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/web-app/src/router.js @@ -9,6 +9,7 @@ const router = (
+
) diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index 066f263bc2..135fb35f85 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -11,7 +11,7 @@ "file", "Dockerfile" ], - "93bb104c26d9c0e06b947bd9b2be153e579f599590d3fa8876db2ca777ce5183" + "faae4a6f87557e624d9c7631ec6f3ed20e31115f2c177bfe19b3f52d163d86e9" ], [ [ @@ -46,21 +46,21 @@ "file", "server/package.json" ], - "9c3186abd79e11706fd56699054b653303dbae26b3b7037a2104377d697b7b22" + "f737ca2e276090fdbc46be2279b41d50973c024bb31259a45206a6cce7c469e9" ], [ [ "file", "server/src/app.js" ], - "31bf8e67e2bc95b2558c649fdb51e6c7a6d373cbc8488141ab8652e8d69604ce" + "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72" ], [ [ "file", "server/src/config.js" ], - "beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50" + "d7f23a370f15869e6a3920552993c41d5e1a23d8e12a4a9d3a583d22b730a5d6" ], [ [ @@ -207,7 +207,7 @@ "file", "web-app/package.json" ], - "8d5e3c23f6fdfe422e307d0dee0e5503d1584fa47a4cff5ca4299e653a6b0fb6" + "0087b0093eca5a67c4e66948f0658f7d18ab371fee633e913877a468715e7d16" ], [ [ @@ -333,7 +333,7 @@ "file", "web-app/src/router.js" ], - "959a870fd08b7bc2eabf397750256946e26960e08fb2887ec65ab5fc775aebfc" + "dd6f5f5c6981df1935c02437d7a6ae343841300c3f7e99b0ad6ca62e95071421" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/Dockerfile b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/Dockerfile index ec86950447..6711e7a7b8 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/Dockerfile +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine AS node +FROM node:16-alpine AS node FROM node AS base diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json index ba3f0505a0..435173c51e 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^8.3.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json index 7666c12c9e..73fe45996f 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/package.json @@ -9,7 +9,9 @@ "helmet": "^4.6.0", "jsonwebtoken": "^8.5.1", "morgan": "~1.9.1", - "secure-password": "^4.0.0" + "patch-package": "^6.4.7", + "secure-password": "^4.0.0", + "uuid": "^8.3.2" }, "devDependencies": { "nodemon": "^2.0.4", @@ -30,6 +32,7 @@ "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma", "db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma", "debug": "DEBUG=server:* npm start", + "postinstall": "patch-package", "standard": "standard", "start": "nodemon -r dotenv/config ./src/server.js", "start-production": "NODE_ENV=production node ./src/server.js" diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js index 6c769e0cd1..7ffcd317d9 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/app.js @@ -6,6 +6,7 @@ import helmet from 'helmet' import HttpError from './core/HttpError.js' import indexRouter from './routes/index.js' +import config from './config.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that // it can be used in unit tests to test each route individually. @@ -13,7 +14,10 @@ import indexRouter from './routes/index.js' const app = express() app.use(helmet()) -app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests. +app.use(cors({ + // TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup. + origin: config.frontendUrl, +})) app.use(logger('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js index a7958f3a3f..2769066bf7 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/config.js @@ -12,10 +12,13 @@ const config = { env, port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, + frontendUrl: undefined, }, development: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000', }, production: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL, } } diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json index 68ae733c56..1f141ebe00 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/package.json @@ -19,8 +19,7 @@ "react-error-overlay": "6.0.9", "react-query": "^3.34.19", "react-router-dom": "^5.1.2", - "react-scripts": "4.0.3", - "uuid": "^3.4.0" + "react-scripts": "4.0.3" }, "devDependencies": {}, "engineStrict": true, diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.js index 5516a073dd..c783ed20ac 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.js +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/web-app/src/router.js @@ -9,6 +9,7 @@ const router = (
+
) diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 910ed85a35..d83c2056ed 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -11,7 +11,7 @@ "file", "Dockerfile" ], - "93bb104c26d9c0e06b947bd9b2be153e579f599590d3fa8876db2ca777ce5183" + "faae4a6f87557e624d9c7631ec6f3ed20e31115f2c177bfe19b3f52d163d86e9" ], [ [ @@ -46,21 +46,21 @@ "file", "server/package.json" ], - "aed180a5eb3804680a90c96e24aa9ae4f78a3c64f46976fccab5b25f2e9e2992" + "fa2e6f47b8fbf4dd35d272992b39138942823a3be759a429373debfd049a153d" ], [ [ "file", "server/src/app.js" ], - "31bf8e67e2bc95b2558c649fdb51e6c7a6d373cbc8488141ab8652e8d69604ce" + "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72" ], [ [ "file", "server/src/config.js" ], - "beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50" + "d7f23a370f15869e6a3920552993c41d5e1a23d8e12a4a9d3a583d22b730a5d6" ], [ [ @@ -221,7 +221,7 @@ "file", "web-app/package.json" ], - "5b5fd1238f4ab4e031b9dde5967358a8d704d9028938071393293b07e906da10" + "1dcfe86b9c226cbaa291136e8df6a9598486f6954ee40701f111ded71a09d93c" ], [ [ @@ -354,7 +354,7 @@ "file", "web-app/src/router.js" ], - "959a870fd08b7bc2eabf397750256946e26960e08fb2887ec65ab5fc775aebfc" + "dd6f5f5c6981df1935c02437d7a6ae343841300c3f7e99b0ad6ca62e95071421" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/Dockerfile b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/Dockerfile index ec86950447..6711e7a7b8 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/Dockerfile +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine AS node +FROM node:16-alpine AS node FROM node AS base diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json index 4f4403bf9a..1a03167950 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"pg-boss","version":"^7.2.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^8.3.2"},{"name":"pg-boss","version":"^7.2.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json index d3cb87c265..ac2ba02c6a 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/package.json @@ -9,8 +9,10 @@ "helmet": "^4.6.0", "jsonwebtoken": "^8.5.1", "morgan": "~1.9.1", + "patch-package": "^6.4.7", "pg-boss": "^7.2.1", - "secure-password": "^4.0.0" + "secure-password": "^4.0.0", + "uuid": "^8.3.2" }, "devDependencies": { "nodemon": "^2.0.4", @@ -31,6 +33,7 @@ "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma", "db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma", "debug": "DEBUG=server:* npm start", + "postinstall": "patch-package", "standard": "standard", "start": "nodemon -r dotenv/config ./src/server.js", "start-production": "NODE_ENV=production node ./src/server.js" diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js index 6c769e0cd1..7ffcd317d9 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/app.js @@ -6,6 +6,7 @@ import helmet from 'helmet' import HttpError from './core/HttpError.js' import indexRouter from './routes/index.js' +import config from './config.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that // it can be used in unit tests to test each route individually. @@ -13,7 +14,10 @@ import indexRouter from './routes/index.js' const app = express() app.use(helmet()) -app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests. +app.use(cors({ + // TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup. + origin: config.frontendUrl, +})) app.use(logger('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js index a7958f3a3f..2769066bf7 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/config.js @@ -12,10 +12,13 @@ const config = { env, port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, + frontendUrl: undefined, }, development: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000', }, production: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL, } } diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json index 9fba8112a2..451b88e760 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/package.json @@ -19,8 +19,7 @@ "react-error-overlay": "6.0.9", "react-query": "^3.34.19", "react-router-dom": "^5.1.2", - "react-scripts": "4.0.3", - "uuid": "^3.4.0" + "react-scripts": "4.0.3" }, "devDependencies": {}, "engineStrict": true, diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.js index 5516a073dd..c783ed20ac 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.js +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/web-app/src/router.js @@ -9,6 +9,7 @@ const router = (
+
) diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index 17c4c7cad5..444d69f2a8 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -11,7 +11,7 @@ "file", "Dockerfile" ], - "a2e26d43bffd368f037c0ed7cb0362331b436c8beb36f952251212564cc2b8df" + "f94ec7a7e7db084cdda6a0860d68693252da43d7ddd28ba990222ea0831c5467" ], [ [ @@ -46,21 +46,21 @@ "file", "server/package.json" ], - "c1483b8ee9fb6b7b19575ee3327d88a92a3be99427a4714b28dfa2c2207434fa" + "532a3c9ba7889699a4974d717dbdc33dfb2167710a788df3f1c3dbd8641dc064" ], [ [ "file", "server/src/app.js" ], - "31bf8e67e2bc95b2558c649fdb51e6c7a6d373cbc8488141ab8652e8d69604ce" + "1e802078a0c6738f9dc2dc8f1739120d28fdc3d6fdc8029671ec9aed73c8ed72" ], [ [ "file", "server/src/config.js" ], - "beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50" + "d7f23a370f15869e6a3920552993c41d5e1a23d8e12a4a9d3a583d22b730a5d6" ], [ [ @@ -207,7 +207,7 @@ "file", "web-app/package.json" ], - "b3843b68d443332680fe36af1262ed79a76b1fb600f91153adf1abbe60902ea7" + "ced9ff47676f583d95a381dfabaa7a5b17bae875dca080b9cd4f5f6fa6321ec4" ], [ [ @@ -333,7 +333,7 @@ "file", "web-app/src/router.js" ], - "959a870fd08b7bc2eabf397750256946e26960e08fb2887ec65ab5fc775aebfc" + "dd6f5f5c6981df1935c02437d7a6ae343841300c3f7e99b0ad6ca62e95071421" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/Dockerfile b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/Dockerfile index 9c3e1644cf..864e12bb78 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/Dockerfile +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine AS node +FROM node:16-alpine AS node FROM node AS base diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json index ba3f0505a0..435173c51e 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/installedFullStackNpmDependencies.json @@ -1 +1 @@ -{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file +{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.15.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^8.3.2"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.15.2"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"react-error-overlay","version":"6.0.9"}],"devDependencies":[]}} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json index 9d1818492a..34e3f356f4 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/package.json @@ -9,7 +9,9 @@ "helmet": "^4.6.0", "jsonwebtoken": "^8.5.1", "morgan": "~1.9.1", - "secure-password": "^4.0.0" + "patch-package": "^6.4.7", + "secure-password": "^4.0.0", + "uuid": "^8.3.2" }, "devDependencies": { "nodemon": "^2.0.4", @@ -30,6 +32,7 @@ "db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma", "db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma", "debug": "DEBUG=server:* npm start", + "postinstall": "patch-package", "standard": "standard", "start": "nodemon -r dotenv/config ./src/server.js", "start-production": "npm run db-migrate-prod && NODE_ENV=production node ./src/server.js" diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js index 6c769e0cd1..7ffcd317d9 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/app.js @@ -6,6 +6,7 @@ import helmet from 'helmet' import HttpError from './core/HttpError.js' import indexRouter from './routes/index.js' +import config from './config.js' // TODO: Consider extracting most of this logic into createApp(routes, path) function so that // it can be used in unit tests to test each route individually. @@ -13,7 +14,10 @@ import indexRouter from './routes/index.js' const app = express() app.use(helmet()) -app.use(cors()) // TODO: Consider configuring CORS to be more restrictive, right now it allows all CORS requests. +app.use(cors({ + // TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup. + origin: config.frontendUrl, +})) app.use(logger('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js index a7958f3a3f..2769066bf7 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/config.js @@ -12,10 +12,13 @@ const config = { env, port: parseInt(process.env.PORT) || 3001, databaseUrl: process.env.DATABASE_URL, + frontendUrl: undefined, }, development: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000', }, production: { + frontendUrl: process.env.WASP_WEB_CLIENT_URL, } } diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json index 64c6866a7a..4207392fa0 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/package.json @@ -19,8 +19,7 @@ "react-error-overlay": "6.0.9", "react-query": "^3.34.19", "react-router-dom": "^5.1.2", - "react-scripts": "4.0.3", - "uuid": "^3.4.0" + "react-scripts": "4.0.3" }, "devDependencies": {}, "engineStrict": true, diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.js index 5516a073dd..c783ed20ac 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.js +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/web-app/src/router.js @@ -9,6 +9,7 @@ const router = (
+
) diff --git a/waspc/examples/todoApp/ext/auth/google.js b/waspc/examples/todoApp/ext/auth/google.js new file mode 100644 index 0000000000..283153617f --- /dev/null +++ b/waspc/examples/todoApp/ext/auth/google.js @@ -0,0 +1,16 @@ +import { generateAvailableUsername } from '@wasp/core/auth.js' + +export function config() { + console.log("Inside user-supplied Google config") + return { + clientId: process.env['GOOGLE_CLIENT_ID'], + clientSecret: process.env['GOOGLE_CLIENT_SECRET'], + scope: ['profile'] + } +} + +export async function getUserFields(_context, args) { + console.log("Inside user-supplied Google getUserFields") + const username = await generateAvailableUsername(args.profile.displayName.split(' '), { separator: '.' }) + return { username } +} diff --git a/waspc/examples/todoApp/ext/pages/auth/Login.js b/waspc/examples/todoApp/ext/pages/auth/Login.js index 54f3fd7fef..9945e8c77c 100644 --- a/waspc/examples/todoApp/ext/pages/auth/Login.js +++ b/waspc/examples/todoApp/ext/pages/auth/Login.js @@ -2,6 +2,7 @@ import React from 'react' import { Link } from 'react-router-dom' import LoginForm from '@wasp/auth/forms/Login' +// import { GoogleSignInButton } from '@wasp/auth/buttons/Google' const Login = () => { return ( @@ -11,6 +12,10 @@ const Login = () => { I don't have an account yet (go to signup). + + {/*
+ +
*/} ) } diff --git a/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql b/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql new file mode 100644 index 0000000000..0ae1754d7d --- /dev/null +++ b/waspc/examples/todoApp/migrations/20220822154342_add_social_login/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "SocialLogin" ( + "id" SERIAL NOT NULL, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId"); + +-- AddForeignKey +ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/todoApp.wasp b/waspc/examples/todoApp/todoApp.wasp index d773f46d3d..6bc48af3df 100644 --- a/waspc/examples/todoApp/todoApp.wasp +++ b/waspc/examples/todoApp/todoApp.wasp @@ -8,7 +8,14 @@ app todoApp { ], auth: { userEntity: User, - methods: [ UsernameAndPassword ], + // externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, + // google: { + // configFn: import { config } from "@ext/auth/google.js", + // getUserFieldsFn: import { getUserFields } from "@ext/auth/google.js" + // } + }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/profile" }, @@ -24,10 +31,21 @@ app todoApp { } entity User {=psl - id Int @id @default(autoincrement()) - username String @unique - password String - tasks Task[] + id Int @id @default(autoincrement()) + username String @unique + password String + tasks Task[] + externalAuthAssociations SocialLogin[] +psl=} + +entity SocialLogin {=psl + id Int @id @default(autoincrement()) + provider String + providerId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + createdAt DateTime @default(now()) + @@unique([provider, providerId, userId]) psl=} entity Task {=psl @@ -38,7 +56,6 @@ entity Task {=psl userId Int psl=} - route SignupRoute { path: "/signup", to: SignupPage } page SignupPage { component: import Signup from "@ext/pages/auth/Signup" @@ -131,7 +148,7 @@ job mySpecialScheduledJob { fn: import { foo } from "@ext/jobs/bar.js" }, schedule: { - cron: "*/2 * * * *", + cron: "0 * * * *", args: {=json { "foo": "bar" } json=}, executorOptions: { pgBoss: {=json { "retryLimit": 2 } json=} diff --git a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs index 34db44e5a8..030f47366d 100644 --- a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs +++ b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs @@ -12,7 +12,6 @@ import qualified Wasp.Analyzer.TypeDefinitions as TD import Wasp.Analyzer.TypeDefinitions.TH (makeDeclType, makeEnumType) import Wasp.AppSpec.Action (Action) import Wasp.AppSpec.App (App) -import Wasp.AppSpec.App.Auth (AuthMethod) import Wasp.AppSpec.App.Db (DbSystem) import Wasp.AppSpec.Entity (Entity) import Wasp.AppSpec.Job (Job, JobExecutor) @@ -20,7 +19,6 @@ import Wasp.AppSpec.Page (Page) import Wasp.AppSpec.Query (Query) import Wasp.AppSpec.Route (Route) -makeEnumType ''AuthMethod makeEnumType ''DbSystem makeDeclType ''App makeDeclType ''Page @@ -37,7 +35,6 @@ makeDeclType ''Job stdTypes :: TD.TypeDefinitions stdTypes = TD.addDeclType @App $ - TD.addEnumType @AuthMethod $ TD.addEnumType @DbSystem $ TD.addDeclType @Entity $ TD.addDeclType @Page $ diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index 9b59e184e2..648c959a8f 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -1,21 +1,58 @@ {-# LANGUAGE DeriveDataTypeable #-} +{-# LANGUAGE DuplicateRecordFields #-} module Wasp.AppSpec.App.Auth ( Auth (..), - AuthMethod (..), + AuthMethods (..), + GoogleConfig (..), + usernameAndPasswordConfig, + isUsernameAndPasswordAuthEnabled, + isGoogleAuthEnabled, + isExternalAuthEnabled, ) where import Data.Data (Data) +import Data.Maybe (isJust) import Wasp.AppSpec.Core.Ref (Ref) import Wasp.AppSpec.Entity (Entity) +import Wasp.AppSpec.ExtImport (ExtImport) data Auth = Auth { userEntity :: Ref Entity, - methods :: [AuthMethod], + externalAuthEntity :: Maybe (Ref Entity), + methods :: AuthMethods, onAuthFailedRedirectTo :: String, onAuthSucceededRedirectTo :: Maybe String } deriving (Show, Eq, Data) -data AuthMethod = UsernameAndPassword deriving (Show, Eq, Data) +data AuthMethods = AuthMethods + { usernameAndPassword :: Maybe UsernameAndPasswordConfig, + google :: Maybe GoogleConfig + } + deriving (Show, Eq, Data) + +data UsernameAndPasswordConfig = UsernameAndPasswordConfig + { -- NOTE: Not used right now, but Analyzer does not support an empty data type. + configFn :: Maybe ExtImport + } + deriving (Show, Eq, Data) + +data GoogleConfig = GoogleConfig + { configFn :: Maybe ExtImport, + getUserFieldsFn :: Maybe ExtImport + } + deriving (Show, Eq, Data) + +usernameAndPasswordConfig :: UsernameAndPasswordConfig +usernameAndPasswordConfig = UsernameAndPasswordConfig Nothing + +isUsernameAndPasswordAuthEnabled :: Auth -> Bool +isUsernameAndPasswordAuthEnabled = isJust . usernameAndPassword . methods + +isGoogleAuthEnabled :: Auth -> Bool +isGoogleAuthEnabled = isJust . google . methods + +isExternalAuthEnabled :: Auth -> Bool +isExternalAuthEnabled auth = any ($ auth) [isGoogleAuthEnabled] diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index 68adfdf5fb..9d57f52ced 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -35,6 +35,7 @@ validateAppSpec spec = concat [ validateAppAuthIsSetIfAnyPageRequiresAuth spec, validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec, + validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec, validateDbIsPostgresIfPgBossUsed spec ] @@ -50,52 +51,74 @@ validateExactlyOneAppExists spec = validateAppAuthIsSetIfAnyPageRequiresAuth :: AppSpec -> [ValidationError] validateAppAuthIsSetIfAnyPageRequiresAuth spec = - if anyPageRequiresAuth && not (isAuthEnabled spec) - then - [ GenericValidationError - "Expected app.auth to be defined since there are Pages with authRequired set to true." - ] - else [] + [ GenericValidationError + "Expected app.auth to be defined since there are Pages with authRequired set to true." + | anyPageRequiresAuth && not (isAuthEnabled spec) + ] where anyPageRequiresAuth = any ((== Just True) . Page.authRequired) (snd <$> AS.getPages spec) validateDbIsPostgresIfPgBossUsed :: AppSpec -> [ValidationError] validateDbIsPostgresIfPgBossUsed spec = - if isPgBossJobExecutorUsed spec && not (isPostgresUsed spec) - then - [ GenericValidationError - "Expected app.db.system to be PostgreSQL since there are jobs with executor set to PgBoss." - ] - else [] + [ GenericValidationError + "Expected app.db.system to be PostgreSQL since there are jobs with executor set to PgBoss." + | isPgBossJobExecutorUsed spec && not (isPostgresUsed spec) + ] validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed :: AppSpec -> [ValidationError] validateAuthUserEntityHasCorrectFieldsIfUsernameAndPasswordAuthIsUsed spec = case App.auth (snd $ getApp spec) of Nothing -> [] Just auth -> - if Auth.UsernameAndPassword `notElem` Auth.methods auth + if not $ Auth.isUsernameAndPasswordAuthEnabled auth then [] else let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth) userEntityFields = Entity.getFields userEntity - maybeUsernameField = find ((== "username") . Entity.Field.fieldName) userEntityFields - maybePasswordField = find ((== "password") . Entity.Field.fieldName) userEntityFields - in concat - [ case maybeUsernameField of - Just usernameField - | Entity.Field.fieldType usernameField == Entity.Field.FieldTypeScalar Entity.Field.String -> [] - _ -> - [ GenericValidationError - "Expected an Entity referenced by app.auth.userEntity to have field 'username' of type 'string'." - ], - case maybePasswordField of - Just passwordField - | Entity.Field.fieldType passwordField == Entity.Field.FieldTypeScalar Entity.Field.String -> [] - _ -> - [ GenericValidationError - "Expected an Entity referenced by app.auth.userEntity to have field 'password' of type 'string'." - ] + in concatMap + (validateEntityHasField "app.auth.userEntity" userEntityFields) + [ ("username", Entity.Field.FieldTypeScalar Entity.Field.String, "String"), + ("password", Entity.Field.FieldTypeScalar Entity.Field.String, "String") ] +validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed :: AppSpec -> [ValidationError] +validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec = case App.auth (snd $ getApp spec) of + Nothing -> [] + Just auth -> + if not $ Auth.isExternalAuthEnabled auth + then [] + else case Auth.externalAuthEntity auth of + Nothing -> [GenericValidationError "app.auth.externalAuthEntity must be specified when using a social login method."] + Just externalAuthEntityRef -> + let (userEntityName, userEntity) = AS.resolveRef spec (Auth.userEntity auth) + userEntityFields = Entity.getFields userEntity + (externalAuthEntityName, externalAuthEntity) = AS.resolveRef spec externalAuthEntityRef + externalAuthEntityFields = Entity.getFields externalAuthEntity + externalAuthEntityValidationErrors = + concatMap + (validateEntityHasField "app.auth.externalAuthEntity" externalAuthEntityFields) + [ ("provider", Entity.Field.FieldTypeScalar Entity.Field.String, "String"), + ("providerId", Entity.Field.FieldTypeScalar Entity.Field.String, "String"), + ("user", Entity.Field.FieldTypeScalar (Entity.Field.UserType userEntityName), userEntityName), + ("userId", Entity.Field.FieldTypeScalar Entity.Field.Int, "Int") + ] + userEntityValidationErrors = + concatMap + (validateEntityHasField "app.auth.userEntity" userEntityFields) + [ ("externalAuthAssociations", Entity.Field.FieldTypeComposite $ Entity.Field.List $ Entity.Field.UserType externalAuthEntityName, externalAuthEntityName ++ "[]") + ] + in externalAuthEntityValidationErrors ++ userEntityValidationErrors + +validateEntityHasField :: String -> [Entity.Field.Field] -> (String, Entity.Field.FieldType, String) -> [ValidationError] +validateEntityHasField entityName entityFields (fieldName, fieldType, fieldTypeName) = + let maybeField = find ((== fieldName) . Entity.Field.fieldName) entityFields + in case maybeField of + Just providerField + | Entity.Field.fieldType providerField == fieldType -> [] + _ -> + [ GenericValidationError $ + "Expected an Entity referenced by " ++ entityName ++ " to have field '" ++ fieldName ++ "' of type '" ++ fieldTypeName ++ "'." + ] + -- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function). -- TODO: It would be great if we could ensure this at type level, but we decided that was too much work for now. -- Check https://github.com/wasp-lang/wasp/pull/455 for considerations on this and analysis of different approaches. diff --git a/waspc/src/Wasp/Generator/Common.hs b/waspc/src/Wasp/Generator/Common.hs index f65c1e0f57..bbe8901660 100644 --- a/waspc/src/Wasp/Generator/Common.hs +++ b/waspc/src/Wasp/Generator/Common.hs @@ -1,5 +1,6 @@ module Wasp.Generator.Common ( ProjectRootDir, + latestMajorNodeVersion, nodeVersionRange, npmVersionRange, prismaVersion, @@ -11,11 +12,19 @@ import qualified Wasp.SemanticVersion as SV -- | Directory where the whole web app project (client, server, ...) is generated. data ProjectRootDir --- | Range of node versions that node packages generated by this generator work correctly with. +-- | Latest concrete major node version supported by the nodeVersionRange, and +-- therefore by Wasp. +-- Here we assume that nodeVersionRange is using latestNodeLTSVersion as its basis. +-- TODO: instead of making assumptions, extract the latest major node version +-- directly from the nodeVersionRange. +latestMajorNodeVersion :: SV.Version +latestMajorNodeVersion = latestNodeLTSVersion + nodeVersionRange :: SV.Range -nodeVersionRange = SV.Range [SV.backwardsCompatibleWith latestLTSVersion] - where - latestLTSVersion = SV.Version 16 0 0 +nodeVersionRange = SV.Range [SV.backwardsCompatibleWith latestNodeLTSVersion] + +latestNodeLTSVersion :: SV.Version +latestNodeLTSVersion = SV.Version 16 0 0 -- | Range of npm versions that Wasp and generated projects work correctly with. npmVersionRange :: SV.Range diff --git a/waspc/src/Wasp/Generator/DockerGenerator.hs b/waspc/src/Wasp/Generator/DockerGenerator.hs index 6548bfc932..76f53393c3 100644 --- a/waspc/src/Wasp/Generator/DockerGenerator.hs +++ b/waspc/src/Wasp/Generator/DockerGenerator.hs @@ -10,24 +10,29 @@ import StrongPath (File', Path', Rel, relfile) import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.Entity as AS.Entity -import Wasp.Generator.Common (ProjectRootDir) +import Wasp.Generator.Common (ProjectRootDir, latestMajorNodeVersion) import Wasp.Generator.FileDraft (FileDraft, createTemplateFileDraft) import Wasp.Generator.Monad (Generator) +import Wasp.Generator.ServerGenerator (areServerPatchesUsed) import Wasp.Generator.Templates (TemplatesDir) +import qualified Wasp.SemanticVersion as SV genDockerFiles :: AppSpec -> Generator [FileDraft] genDockerFiles spec = sequence [genDockerfile spec, genDockerignore spec] -- TODO: Inject paths to server and db files/dirs, right now they are hardcoded in the templates. genDockerfile :: AppSpec -> Generator FileDraft -genDockerfile spec = +genDockerfile spec = do + usingServerPatches <- areServerPatchesUsed spec return $ createTemplateFileDraft ([relfile|Dockerfile|] :: Path' (Rel ProjectRootDir) File') ([relfile|Dockerfile|] :: Path' (Rel TemplatesDir) File') ( Just $ object - [ "usingPrisma" .= not (null $ AS.getDecls @AS.Entity.Entity spec) + [ "usingPrisma" .= not (null $ AS.getDecls @AS.Entity.Entity spec), + "nodeMajorVersion" .= show (SV.major latestMajorNodeVersion), + "usingServerPatches" .= usingServerPatches ] ) diff --git a/waspc/src/Wasp/Generator/ServerGenerator.hs b/waspc/src/Wasp/Generator/ServerGenerator.hs index ca5d476786..5b804b6740 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator.hs @@ -4,6 +4,7 @@ module Wasp.Generator.ServerGenerator ( genServer, operationsRouteInRootRouter, npmDepsForWasp, + areServerPatchesUsed, ) where @@ -30,6 +31,7 @@ import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import qualified Wasp.AppSpec.App.Dependency as AS.Dependency +import qualified Wasp.AppSpec.App.Dependency as App.Dependency import qualified Wasp.AppSpec.App.Server as AS.App.Server import qualified Wasp.AppSpec.Entity as AS.Entity import Wasp.AppSpec.Util (isPgBossJobExecutorUsed) @@ -63,6 +65,7 @@ genServer spec = <++> genDotEnv spec <++> genJobs spec <++> genJobExecutors + <++> genPatches spec genDotEnv :: AppSpec -> Generator [FileDraft] genDotEnv spec = return $ @@ -115,8 +118,11 @@ npmDepsForWasp spec = ("jsonwebtoken", "^8.5.1"), ("secure-password", "^4.0.0"), ("dotenv", "8.2.0"), - ("helmet", "^4.6.0") + ("helmet", "^4.6.0"), + ("patch-package", "^6.4.7"), + ("uuid", "^8.3.2") ] + ++ depsRequiredByPassport spec ++ depsRequiredByJobs spec, N.waspDevDependencies = AS.Dependency.fromList @@ -220,3 +226,28 @@ genRoutesDir spec = operationsRouteInRootRouter :: String operationsRouteInRootRouter = "operations" + +depsRequiredByPassport :: AppSpec -> [App.Dependency.Dependency] +depsRequiredByPassport spec = + AS.Dependency.fromList $ + concat + [ [("passport", "0.6.0") | (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True], + [("passport-google-oauth20", "2.0.0") | (AS.App.Auth.isGoogleAuthEnabled <$> maybeAuth) == Just True] + ] + where + maybeAuth = AS.App.auth $ snd $ getApp spec + +areServerPatchesUsed :: AppSpec -> Generator Bool +areServerPatchesUsed spec = not . null <$> genPatches spec + +genPatches :: AppSpec -> Generator [FileDraft] +genPatches spec = patchesRequiredByPassport spec + +patchesRequiredByPassport :: AppSpec -> Generator [FileDraft] +patchesRequiredByPassport spec = + return $ + [ C.mkTmplFd (C.asTmplFile [relfile|patches/oauth+0.9.15.patch|]) + | (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True + ] + where + maybeAuth = AS.App.auth $ snd $ getApp spec diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index fcf55ab659..c3a10fb783 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -4,15 +4,31 @@ module Wasp.Generator.ServerGenerator.AuthG where import Data.Aeson (object, (.=)) -import StrongPath (reldir, relfile, ()) +import Data.Maybe (fromMaybe, isJust) +import StrongPath + ( Dir, + File', + Path, + Path', + Posix, + Rel, + reldir, + reldirP, + relfile, + (), + ) +import qualified StrongPath as SP import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.Auth import Wasp.AppSpec.Valid (getApp) +import Wasp.Generator.ExternalCodeGenerator.Common (GeneratedExternalCodeDir) import Wasp.Generator.FileDraft (FileDraft) +import Wasp.Generator.JsImport (getJsImportDetailsForExtFnImport) import Wasp.Generator.Monad (Generator) import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Util ((<++>)) import qualified Wasp.Util as Util genAuth :: AppSpec -> Generator [FileDraft] @@ -22,11 +38,13 @@ genAuth spec = case maybeAuth of [ genCoreAuth auth, genAuthMiddleware auth, -- Auth routes - genAuthRoutesIndex, + genAuthRoutesIndex auth, genLoginRoute auth, genSignupRoute auth, - genMeRoute auth + genMeRoute auth, + genUtilsJs auth ] + <++> genPassportAuth auth Nothing -> return [] where maybeAuth = AS.App.auth $ snd $ getApp spec @@ -65,8 +83,18 @@ genAuthMiddleware auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Jus [ "userEntityUpper" .= (userEntityName :: String) ] -genAuthRoutesIndex :: Generator FileDraft -genAuthRoutesIndex = return $ C.mkSrcTmplFd (C.asTmplSrcFile [relfile|routes/auth/index.js|]) +genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft +genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir SP.castRel authIndexFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir authIndexFileInSrcDir + tmplData = + object + [ "isExternalAuthEnabled" .= AS.Auth.isExternalAuthEnabled auth + ] + + authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + authIndexFileInSrcDir = [relfile|routes/auth/index.js|] genLoginRoute :: AS.Auth.Auth -> Generator FileDraft genLoginRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) @@ -105,3 +133,85 @@ genMeRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplD object [ "userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String) ] + +genPassportAuth :: AS.Auth.Auth -> Generator [FileDraft] +genPassportAuth auth + | AS.Auth.isExternalAuthEnabled auth = (:) <$> genPassportJs auth <*> genGoogleAuth auth + | otherwise = return [] + +genPassportJs :: AS.Auth.Auth -> Generator FileDraft +genPassportJs auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir SP.castRel passportFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir passportFileInSrcDir + tmplData = + object + [ "isGoogleAuthEnabled" .= AS.Auth.isGoogleAuthEnabled auth + ] + + passportFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + passportFileInSrcDir = [relfile|routes/auth/passport/passport.js|] + +genUtilsJs :: AS.Auth.Auth -> Generator FileDraft +genUtilsJs auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + userEntityName = AS.refName $ AS.Auth.userEntity auth + externalAuthEntityName = maybe "undefined" AS.refName (AS.Auth.externalAuthEntity auth) + tmplFile = C.srcDirInServerTemplatesDir SP.castRel utilsFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir utilsFileInSrcDir + tmplData = + object + [ "userEntityUpper" .= (userEntityName :: String), + "userEntityLower" .= (Util.toLowerFirst userEntityName :: String), + "externalAuthEntityLower" .= (Util.toLowerFirst externalAuthEntityName :: String), + "failureRedirectPath" .= AS.Auth.onAuthFailedRedirectTo auth, + "successRedirectPath" .= getOnAuthSucceededRedirectToOrDefault auth + ] + + utilsFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + utilsFileInSrcDir = [relfile|routes/auth/utils.js|] + +genGoogleAuth :: AS.Auth.Auth -> Generator [FileDraft] +genGoogleAuth auth + | AS.Auth.isGoogleAuthEnabled auth = + sequence + [ copyTmplFile [relfile|routes/auth/passport/google/google.js|], + copyTmplFile [relfile|routes/auth/passport/google/googleDefaults.js|], + genGoogleConfigJs auth + ] + | otherwise = return [] + where + copyTmplFile = return . C.mkSrcTmplFd + +genGoogleConfigJs :: AS.Auth.Auth -> Generator FileDraft +genGoogleConfigJs auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData) + where + tmplFile = C.srcDirInServerTemplatesDir SP.castRel googleConfigFileInSrcDir + dstFile = C.serverSrcDirInServerRootDir googleConfigFileInSrcDir + tmplData = + object + [ "doesConfigFnExist" .= isJust maybeConfigFn, + "configFnImportStatement" .= fromMaybe "" maybeConfigFnImportStmt, + "configFnIdentifier" .= fromMaybe "" maybeConfigFnImportIdentifier, + "doesOnSignInFnExist" .= isJust maybeGetUserFieldsFn, + "getUserFieldsFnImportStatement" .= fromMaybe "" maybeOnSignInFnImportStmt, + "getUserFieldsFnIdentifier" .= fromMaybe "" maybeOnSignInFnImportIdentifier + ] + + googleConfigFileInSrcDir :: Path' (Rel C.ServerSrcDir) File' + googleConfigFileInSrcDir = [relfile|routes/auth/passport/google/googleConfig.js|] + + maybeConfigFn = AS.Auth.configFn =<< AS.Auth.google (AS.Auth.methods auth) + maybeConfigFnImportDetails = getJsImportDetailsForExtFnImport relPosixPathFromGoogleAuthDirToExtSrcDir <$> maybeConfigFn + (maybeConfigFnImportIdentifier, maybeConfigFnImportStmt) = (fst <$> maybeConfigFnImportDetails, snd <$> maybeConfigFnImportDetails) + + maybeGetUserFieldsFn = AS.Auth.getUserFieldsFn =<< AS.Auth.google (AS.Auth.methods auth) + maybeOnSignInFnImportDetails = getJsImportDetailsForExtFnImport relPosixPathFromGoogleAuthDirToExtSrcDir <$> maybeGetUserFieldsFn + (maybeOnSignInFnImportIdentifier, maybeOnSignInFnImportStmt) = (fst <$> maybeOnSignInFnImportDetails, snd <$> maybeOnSignInFnImportDetails) + +-- | TODO: Make this not hardcoded! +relPosixPathFromGoogleAuthDirToExtSrcDir :: Path Posix (Rel (Dir C.ServerSrcDir)) (Dir GeneratedExternalCodeDir) +relPosixPathFromGoogleAuthDirToExtSrcDir = [reldirP|../../../../ext-src|] + +getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String +getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator.hs index b2c101af73..7e9982c774 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator.hs @@ -12,14 +12,13 @@ import StrongPath Path, Posix, Rel, - reldir, relfile, - (), ) import StrongPath.TH (reldirP) import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import Wasp.AppSpec.App.Client as AS.App.Client import qualified Wasp.AppSpec.App.Dependency as AS.Dependency import Wasp.AppSpec.Valid (getApp) @@ -90,7 +89,6 @@ npmDepsForWasp _spec = ("react-query", "^3.34.19"), ("react-router-dom", "^5.1.2"), ("react-scripts", "4.0.3"), - ("uuid", "^3.4.0"), -- NOTE: We need to specify this exact version of `react-error-overlay` for use with -- `react-scripts` v4 due to this issue: https://github.com/facebook/create-react-app/issues/11773 ("react-error-overlay", "6.0.9") @@ -113,14 +111,22 @@ genPublicDir :: AppSpec -> Generator [FileDraft] genPublicDir spec = do publicIndexHtmlFd <- genPublicIndexHtml spec return $ - C.mkTmplFd (C.asTmplFile [relfile|public/favicon.ico|]) : - publicIndexHtmlFd : - ( let tmplData = object ["appName" .= (fst (getApp spec) :: String)] - processPublicTmpl path = C.mkTmplFdWithData (C.asTmplFile $ [reldir|public|] path) tmplData - in processPublicTmpl - <$> [ [relfile|manifest.json|] - ] - ) + [ publicIndexHtmlFd, + genFaviconFd, + genManifestFd + ] + ++ genGoogleSigninImage + where + maybeAuth = AS.App.auth $ snd $ getApp spec + genFaviconFd = C.mkTmplFd (C.asTmplFile [relfile|public/favicon.ico|]) + genGoogleSigninImage = + [ C.mkTmplFd (C.asTmplFile [relfile|public/images/btn_google_signin_dark_normal_web@2x.png|]) + | (AS.App.Auth.isGoogleAuthEnabled <$> maybeAuth) == Just True + ] + genManifestFd = + let tmplData = object ["appName" .= (fst (getApp spec) :: String)] + tmplFile = C.asTmplFile [relfile|public/manifest.json|] + in C.mkTmplFdWithData tmplFile tmplData genPublicIndexHtml :: AppSpec -> Generator FileDraft genPublicIndexHtml spec = diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs index c43d503b81..56f4f73d4e 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/AuthG.hs @@ -9,6 +9,7 @@ import Data.Maybe (fromMaybe) import StrongPath (File', Path', Rel', reldir, relfile, ()) import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import qualified Wasp.AppSpec.App.Auth as AS.Auth import Wasp.AppSpec.Valid (getApp) import Wasp.Generator.FileDraft (FileDraft) @@ -63,20 +64,36 @@ genAuthForms auth = [ genLoginForm auth, genSignupForm auth ] + <++> genExternalAuth auth genLoginForm :: AS.Auth.Auth -> Generator FileDraft genLoginForm auth = - -- TODO: Logic that says "/" is a default redirect on success is duplicated here and in the function below. - -- We should remove that duplication. compileTmplToSamePath [relfile|auth/forms/Login.js|] - ["onAuthSucceededRedirectTo" .= fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth)] + ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth] genSignupForm :: AS.Auth.Auth -> Generator FileDraft genSignupForm auth = compileTmplToSamePath [relfile|auth/forms/Signup.js|] - ["onAuthSucceededRedirectTo" .= fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth)] + ["onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth] + +genExternalAuth :: AS.Auth.Auth -> Generator [FileDraft] +genExternalAuth auth + | AS.App.Auth.isExternalAuthEnabled auth = (:) <$> genOAuthCodeExchange auth <*> genSocialLoginButtons auth + | otherwise = return [] + +genSocialLoginButtons :: AS.Auth.Auth -> Generator [FileDraft] +genSocialLoginButtons auth = + return [C.mkTmplFd (C.asTmplFile [relfile|src/auth/buttons/Google.js|]) | AS.App.Auth.isGoogleAuthEnabled auth] + +genOAuthCodeExchange :: AS.Auth.Auth -> Generator FileDraft +genOAuthCodeExchange auth = + compileTmplToSamePath + [relfile|auth/pages/OAuthCodeExchange.js|] + [ "onAuthSucceededRedirectTo" .= getOnAuthSucceededRedirectToOrDefault auth, + "onAuthFailedRedirectTo" .= AS.Auth.onAuthFailedRedirectTo auth + ] compileTmplToSamePath :: Path' Rel' File' -> [Pair] -> Generator FileDraft compileTmplToSamePath tmplFileInTmplSrcDir keyValuePairs = @@ -88,3 +105,6 @@ compileTmplToSamePath tmplFileInTmplSrcDir keyValuePairs = where targetPath = C.webAppSrcDirInWebAppRootDir asWebAppSrcFile tmplFileInTmplSrcDir templateData = object keyValuePairs + +getOnAuthSucceededRedirectToOrDefault :: AS.Auth.Auth -> String +getOnAuthSucceededRedirectToOrDefault auth = fromMaybe "/" (AS.Auth.onAuthSucceededRedirectTo auth) diff --git a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs index 3c65c31a41..013a659686 100644 --- a/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs +++ b/waspc/src/Wasp/Generator/WebAppGenerator/RouterGenerator.hs @@ -13,10 +13,12 @@ import qualified StrongPath as SP import qualified System.FilePath as FP import Wasp.AppSpec (AppSpec) import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.App as AS.App +import qualified Wasp.AppSpec.App.Auth as AS.App.Auth import qualified Wasp.AppSpec.ExtImport as AS.ExtImport import qualified Wasp.AppSpec.Page as AS.Page import qualified Wasp.AppSpec.Route as AS.Route -import Wasp.AppSpec.Valid (isAuthEnabled) +import Wasp.AppSpec.Valid (getApp, isAuthEnabled) import Wasp.Generator.FileDraft (FileDraft) import Wasp.Generator.Monad (Generator) import Wasp.Generator.WebAppGenerator.Common (asTmplFile, asWebAppSrcFile) @@ -25,7 +27,9 @@ import qualified Wasp.Generator.WebAppGenerator.Common as C data RouterTemplateData = RouterTemplateData { _routes :: ![RouteTemplateData], _pagesToImport :: ![PageTemplateData], - _isAuthEnabled :: Bool + _isAuthEnabled :: Bool, + _isExternalAuthEnabled :: Bool, + _isGoogleAuthEnabled :: Bool } instance ToJSON RouterTemplateData where @@ -33,7 +37,9 @@ instance ToJSON RouterTemplateData where object [ "routes" .= _routes routerTD, "pagesToImport" .= _pagesToImport routerTD, - "isAuthEnabled" .= _isAuthEnabled routerTD + "isAuthEnabled" .= _isAuthEnabled routerTD, + "isExternalAuthEnabled" .= _isExternalAuthEnabled routerTD, + "isGoogleAuthEnabled" .= _isGoogleAuthEnabled routerTD ] data RouteTemplateData = RouteTemplateData @@ -78,11 +84,14 @@ createRouterTemplateData spec = RouterTemplateData { _routes = routes, _pagesToImport = pages, - _isAuthEnabled = isAuthEnabled spec + _isAuthEnabled = isAuthEnabled spec, + _isExternalAuthEnabled = (AS.App.Auth.isExternalAuthEnabled <$> maybeAuth) == Just True, + _isGoogleAuthEnabled = (AS.App.Auth.isGoogleAuthEnabled <$> maybeAuth) == Just True } where routes = map (createRouteTemplateData spec) $ AS.getRoutes spec pages = map createPageTemplateData $ AS.getPages spec + maybeAuth = AS.App.auth $ snd $ getApp spec createRouteTemplateData :: AppSpec -> (String, AS.Route.Route) -> RouteTemplateData createRouteTemplateData spec namedRoute@(_, route) = diff --git a/waspc/src/Wasp/Util.hs b/waspc/src/Wasp/Util.hs index 13187e76cf..bccf8bc9c7 100644 --- a/waspc/src/Wasp/Util.hs +++ b/waspc/src/Wasp/Util.hs @@ -31,7 +31,7 @@ module Wasp.Util ) where -import Control.Monad (liftM2) +import Control.Applicative (liftA2) import qualified Crypto.Hash.SHA256 as SHA256 import qualified Data.Aeson as Aeson import qualified Data.ByteString as B @@ -155,13 +155,13 @@ insertAt theInsert idx host = infixr 5 <++> -(<++>) :: Monad m => m [a] -> m [a] -> m [a] -(<++>) = liftM2 (++) +(<++>) :: Applicative f => f [a] -> f [a] -> f [a] +(<++>) = liftA2 (++) infixr 5 <:> -(<:>) :: Monad m => m a -> m [a] -> m [a] -(<:>) = liftM2 (:) +(<:>) :: Applicative f => f a -> f [a] -> f [a] +(<:>) = liftA2 (:) ifM :: Monad m => m Bool -> m a -> m a -> m a ifM p x y = p >>= \b -> if b then x else y diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index 8ca33c0d0e..d468d5194b 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -42,7 +42,7 @@ spec_Analyzer = do " head: [\"foo\", \"bar\"],", " auth: {", " userEntity: User,", - " methods: [UsernameAndPassword],", + " methods: { usernameAndPassword: {} },", " onAuthFailedRedirectTo: \"/\",", " },", " dependencies: [", @@ -114,7 +114,12 @@ spec_Analyzer = do Just Auth.Auth { Auth.userEntity = Ref "User" :: Ref Entity, - Auth.methods = [Auth.UsernameAndPassword], + Auth.externalAuthEntity = Nothing, + Auth.methods = + Auth.AuthMethods + { Auth.usernameAndPassword = Just Auth.usernameAndPasswordConfig, + Auth.google = Nothing + }, Auth.onAuthFailedRedirectTo = "/", Auth.onAuthSucceededRedirectTo = Nothing }, diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index e2f3e5314f..0eebf68b1e 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -53,7 +53,12 @@ spec_AppSpecValid = do let validAppAuth = AS.Auth.Auth { AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, - AS.Auth.methods = [AS.Auth.UsernameAndPassword], + AS.Auth.externalAuthEntity = Nothing, + AS.Auth.methods = + AS.Auth.AuthMethods + { AS.Auth.usernameAndPassword = Just AS.Auth.usernameAndPasswordConfig, + AS.Auth.google = Nothing + }, AS.Auth.onAuthFailedRedirectTo = "/", AS.Auth.onAuthSucceededRedirectTo = Nothing } @@ -112,11 +117,11 @@ spec_AppSpecValid = do it "returns an error if app.auth is set and user entity is of invalid shape" $ do ASV.validateAppSpec (makeSpec (Just validAppAuth) invalidUserEntity) `shouldBe` [ ASV.GenericValidationError - "Expected an Entity referenced by app.auth.userEntity to have field 'username' of type 'string'." + "Expected an Entity referenced by app.auth.userEntity to have field 'username' of type 'String'." ] ASV.validateAppSpec (makeSpec (Just validAppAuth) invalidUserEntity2) `shouldBe` [ ASV.GenericValidationError - "Expected an Entity referenced by app.auth.userEntity to have field 'password' of type 'string'." + "Expected an Entity referenced by app.auth.userEntity to have field 'password' of type 'String'." ] where makeBasicPslField name typ = diff --git a/waspc/waspc.cabal b/waspc/waspc.cabal index 4d2e8b06a8..f06dfb9b65 100644 --- a/waspc/waspc.cabal +++ b/waspc/waspc.cabal @@ -28,6 +28,7 @@ data-files: Generator/templates/dockerignore Generator/templates/react-app/gitignore Generator/templates/react-app/npmrc + Generator/templates/server/patches/*.patch Generator/templates/server/gitignore Generator/templates/server/npmrc Generator/templates/**/*.prisma diff --git a/web/blog/2021-03-02-wasp-alpha.md b/web/blog/2021-03-02-wasp-alpha.md index e040f239e2..389b3a05cd 100644 --- a/web/blog/2021-03-02-wasp-alpha.md +++ b/web/blog/2021-03-02-wasp-alpha.md @@ -64,7 +64,9 @@ page Main { auth { /* full-stack auth out-of-the-box */ userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + } } entity User {=psl diff --git a/web/blog/2022-06-24-ML-code-gen-vs-coding-by-hand-future.md b/web/blog/2022-06-24-ML-code-gen-vs-coding-by-hand-future.md index d16d1588ae..e6c4f5efde 100644 --- a/web/blog/2022-06-24-ML-code-gen-vs-coding-by-hand-future.md +++ b/web/blog/2022-06-24-ML-code-gen-vs-coding-by-hand-future.md @@ -169,7 +169,11 @@ If we try to apply the principles from above (less code, less detailed instructi ```css auth: { userEntity: User, - methods: [ UsernameAndPassword, LinkedIn, Google ], + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, + google: {} + }, onAuthFailedRedirectTo: "/login", onAuthSucceededRedirectTo: "/dashboard" } diff --git a/web/docs/deploying.md b/web/docs/deploying.md index 760c366c9c..229acf00c6 100644 --- a/web/docs/deploying.md +++ b/web/docs/deploying.md @@ -30,9 +30,10 @@ Below we will explain the required env vars and also provide detailed instructio ### Env vars Server uses following environment variables, so you need to ensure they are set on your hosting provider: -- `PORT` -> number of port at which it will listen for requests (e.g. `3001`). -- `DATABASE_URL` -> url to the Postgres database that it should use (e.g. `postgresql://mydbuser:mypass@localhost:5432/nameofmydb`) -- `JWT_SECRET` -> you need this if you are using Wasp's `auth` feature. Set it to a random string (password), at least 32 characters long. +- `PORT` -> The port number at which it will listen for requests (e.g. `3001`). +- `DATABASE_URL` -> The URL of the Postgres database it should use (e.g. `postgresql://mydbuser:mypass@localhost:5432/nameofmydb`). +- `WASP_WEB_CLIENT_URL` -> The URL of where the frontend app is running (e.g. `https://.netlify.app`), which is necessary for CORS. +- `JWT_SECRET` -> You need this if you are using Wasp's `auth` feature. Set it to a random string (password), at least 32 characters long. ### Deploying to Heroku @@ -53,11 +54,14 @@ heroku addons:create --app heroku-postgresql:hobby-dev ``` Heroku will also set `DATABASE_URL` env var for us at this point. If you are using external database, you will have to set it yourself. -`PORT` env var will also be provided by Heroku, so the only thing left is to set `JWT_SECRET` env var: +The `PORT` env var will also be provided by Heroku, so the only two left to set are the `JWT_SECRET` and `WASP_WEB_CLIENT_URL` env vars: ``` heroku config:set --app JWT_SECRET= +heroku config:set --app WASP_WEB_CLIENT_URL= ``` +NOTE: If you do not know what your frontend URL is yet, don't worry. You can set WASP_WEB_CLIENT_URL after you deploy your frontend. + #### Deploy to a Heroku app Position yourself in `.wasp/build/` directory (reminder: which you created by running `wasp build` previously): ``` @@ -137,3 +141,5 @@ netlify deploy and carefully follow their instructions (i.e. do you want to create a new app or use existing one, team under which your app will reside, ..., final step to run `netlify deploy --prod`). That is it! + +NOTE: Make sure you set this URL as the `WASP_WEB_CLIENT_URL` environment variable in Heroku. diff --git a/web/docs/integrations/google.md b/web/docs/integrations/google.md new file mode 100644 index 0000000000..d66db7d8e0 --- /dev/null +++ b/web/docs/integrations/google.md @@ -0,0 +1,67 @@ +--- +title: Google Integrations +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; + +# Google Integrations + +## Google Auth + +To use Google as an authentication method (covered [here](/docs/language/features#google)), you'll first need to create a Google project and provide Wasp with your client key and secret. Here is how to do so: + +1. Create a Google Cloud Platform account if you do not already have one: https://cloud.google.com/ +2. Create and configure a new Google project here: https://console.cloud.google.com/home/dashboard + + ![Google Console Screenshot 1](../../static/img/integrations-google-1.jpg) + + ![Google Console Screenshot 2](../../static/img/integrations-google-2.jpg) + +3. Search for `OAuth` in the top bar, click on `OAuth consent screen` + + ![Google Console Screenshot 3](../../static/img/integrations-google-3.jpg) + + - Select what type of app you want, we will go External + + ![Google Console Screenshot 4](../../static/img/integrations-google-4.jpg) + + - Fill out applicable information on Page 1 + + ![Google Console Screenshot 5](../../static/img/integrations-google-5.jpg) + + - On Page 2, Scopes, you should select `userinfo.profile`. You can optionally search for other things, like `email`. + + ![Google Console Screenshot 6](../../static/img/integrations-google-6.jpg) + + ![Google Console Screenshot 7](../../static/img/integrations-google-7.jpg) + + ![Google Console Screenshot 8](../../static/img/integrations-google-8.jpg) + + - Add any test users you want on Page 3 + + ![Google Console Screenshot 9](../../static/img/integrations-google-9.jpg) + +4. Next, click `Credentials` + + ![Google Console Screenshot 10](../../static/img/integrations-google-10.jpg) + + - Select `+ Create Credentials` + - Select `OAuth client ID` + + ![Google Console Screenshot 11](../../static/img/integrations-google-11.jpg) + + - Complete the form + + ![Google Console Screenshot 12](../../static/img/integrations-google-12.jpg) + + - Under Authorized redirect URIs, put in: `http://localhost:3000/auth/login/google` + + ![Google Console Screenshot 13](../../static/img/integrations-google-13.jpg) + + - Once you know on which URL(s) your API server will be deployed, also add those URL(s) + - For example: `https://someotherhost.com/auth/login/google` + - When you save, you can click the Edit icon and your credentials will be shown + + ![Google Console Screenshot 14](../../static/img/integrations-google-14.jpg) + +5. Copy your Client ID and Client secret, and expose them as environment variables named `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` wherever your app is running diff --git a/web/docs/language/features.md b/web/docs/language/features.md index 69e78f99ff..7b7bff6e2c 100644 --- a/web/docs/language/features.md +++ b/web/docs/language/features.md @@ -675,7 +675,9 @@ app MyApp { // ... auth: { userEntity: User, - methods: [ UsernameAndPassword ], + methods: { + usernameAndPassword: {} + }, onAuthFailedRedirectTo: "/someRoute" } } @@ -686,9 +688,13 @@ app MyApp { #### `userEntity: entity` (required) Entity which represents the user (sometimes also referred to as *Principal*). -#### `methods: [AuthMethod]` (required) +#### `externalAuthEntity: entity` (optional) +Entity which associates a user with some external authentication provider. We currently offer support for [Google](#google). + +#### `methods: dict` (required) List of authentication methods that Wasp app supports. Currently supported methods are: -* `UsernameAndPassword`: Provides support for authentication with a username and password. +* `usernameAndPassword`: Provides support for authentication with a username and password. See [here](#username-and-password) for more. +* `google`: Provides support for login via Google accounts. See [here](#google) for more. #### `onAuthFailedRedirectTo: String` (required) Path where an unauthenticated user will be redirected to if they try to access a private page (which is declared by setting `authRequired: true` for a specific page). @@ -700,7 +706,7 @@ Default value is "/". ### Username and Password -`UsernameAndPassword` authentication method makes it possible to signup/login into the app by using a username and password. +`usernameAndPassword` authentication method makes it possible to signup/login into the app by using a username and password. This method requires that `userEntity` specified in `auth` contains `username: string` and `password: string` fields. We provide basic validations out of the box, which you can customize as shown below. Default validations are: @@ -937,6 +943,115 @@ import AuthError from '@wasp/core/AuthError.js' } ``` +### Google + +`google` authentication makes it possible to use Google's OAuth 2.0 service to sign Google users into your app. To enable it, add `google: {}` to your `auth.methods` dictionary to use it with default settings. If you require custom configuration setup or user entity field assignment, you can override the defaults. + +This method requires that `externalAuthEntity` specified in `auth` [described here](features#externalauthentity). +#### Default settings +- Configuration: + - By default, Wasp expects you to set two environment variables in order to use Google authentication: + - `GOOGLE_CLIENT_ID` + - `GOOGLE_CLIENT_SECRET` + - These can be obtained in your Google Cloud Console project dashboard. See [here](/docs/integrations/google#google-auth) for more. +- Sign in: + - When a user signs in for the first time, Wasp will create a new User account and link it to their Google account for future logins. The `username` will default to a random dictionary phrase that does not exist in the database, like "nice-blue-horse-27160". + - Aside: If you would like to allow the user to select their own username, or some other sign up flow, you could add a boolean property to your User entity which indicates if the account setup is complete. You can then redirect them in your `onAuthSucceededRedirectTo` handler. +- Here is a link to the default implementations: https://github.com/wasp-lang/wasp/blob/main/waspc/data/Generator/templates/server/src/routes/auth/passport/google/googleDefaults.js These can be overriden as explained below. + +#### Overrides +If you require modifications to the above, you can add one or more of the following to your `auth.methods.google` dictionary: + +```js + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, + methods: { + google: { + configFn: import { config } from "@ext/auth/google.js", + getUserFieldsFn: import { getUserFields } from "@ext/auth/google.js" + } + }, + ... + } +``` + +- `configFn`: This function should return an object with the following shape: + ```js + export function config() { + // ... + return { + clientId, // look up from env or elsewhere, + clientSecret, // look up from env or elsewhere, + scope: ['profile'] // must include at least 'profile' + } + } + ``` +- `getUserFieldsFn`: This function should return the user fields to use when creating a new user upon their first Google login. The context contains a User entity for DB access, and the args are what the OAuth provider responds with. Here is how you could generate a username based on the Google display name. In your model, you could choose to add more attributes and set additional information. + ```js + import { generateAvailableUsername } from '@wasp/core/auth.js' + + export async function getUserFields(_context, args) { + const username = await generateAvailableUsername(args.profile.displayName.split(' '), { separator: '.' }) + return { username } + } + ``` + - `generateAvailableUsername` takes an array of Strings and an optional separator and generates a string ending with a random number that is not yet in the database. For example, the above could produce something like "Jim.Smith.3984" for a Google user Jim Smith. + +#### UI helpers + +To use the Google sign-in button or URL on your login page, do either of the following: + +```js +... +import { GoogleSignInButton, googleSignInUrl } from '@wasp/auth/buttons/Google' + +const Login = () => { + return ( + <> + ... + + + {/* or */} + Sign in with Google + + ) +} + +export default Login +``` + +You can set the height of the button by setting a prop (e.g., ``), which defaults to 40px. + + +### `externalAuthEntity` +Anytime an authentication method is used that relies on an external authorization provider, for example, Google, we require an `externalAuthEntity` specified in `auth` that contains at least the following highlighted fields: + +```css {4,11,16-19,21} +... + auth: { + userEntity: User, + externalAuthEntity: SocialLogin, +... + +entity User {=psl + id Int @id @default(autoincrement()) + username String @unique + password String + externalAuthAssociations SocialLogin[] +psl=} + +entity SocialLogin {=psl + id Int @id @default(autoincrement()) + provider String + providerId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + createdAt DateTime @default(now()) + @@unique([provider, providerId, userId]) +psl=} +``` + ## Client configuration You can configure the client using the `client` field inside the `app` diff --git a/web/docs/language/syntax.md b/web/docs/language/syntax.md index 768aeaa666..e78fcbe7f8 100644 --- a/web/docs/language/syntax.md +++ b/web/docs/language/syntax.md @@ -70,7 +70,6 @@ While fundamental types are here to be basic building blocks of a language, and - **query** - **route** - Enum types - - **AuthMethod** - **DbSystem** - **JobExecutor** diff --git a/web/docs/tutorials/todo-app/auth.md b/web/docs/tutorials/todo-app/auth.md index 6fd41f2acd..35498ed855 100644 --- a/web/docs/tutorials/todo-app/auth.md +++ b/web/docs/tutorials/todo-app/auth.md @@ -36,14 +36,16 @@ to propagate the schema change (we added User). ## Defining `app.auth` Next, we want to tell Wasp that we want full-stack [authentication](language/features.md#authentication--authorization) in our app, and that it should use entity `User` for it: -```c {4-9} title="main.wasp" +```c {4-11} title="main.wasp" app TodoApp { title: "Todo app", auth: { // Expects entity User to have (username:String) and (password:String) fields. userEntity: User, - methods: [ UsernameAndPassword ], // More methods coming soon! + methods: { + usernameAndPassword: {} // We also support Google, with more on the way! + } onAuthFailedRedirectTo: "/login" // We'll see how this is used a bit later } } diff --git a/web/sidebars.js b/web/sidebars.js index a8edf79e03..d61d5afe9d 100644 --- a/web/sidebars.js +++ b/web/sidebars.js @@ -53,6 +53,14 @@ module.exports = { 'cli', 'deploying', 'examples', + { + type: 'category', + label: 'Integrations', + collapsed: false, + items: [ + 'integrations/google' + ] + }, { type: 'category', label: 'Other', diff --git a/web/src/pages/index.js b/web/src/pages/index.js index 3ecd9af033..7d6775cf04 100644 --- a/web/src/pages/index.js +++ b/web/src/pages/index.js @@ -125,7 +125,11 @@ function HeroCodeExample() { auth: { /* full-stack auth out-of-the-box */ userEntity: User, - methods: [ UsernameAndPassword ], + externalAuthEntity: SocialLogin, + methods: { + usernameAndPassword: {}, + google: {} + } } } @@ -199,7 +203,9 @@ export default () => Hello World! /* full-stack auth out-of-the-box */ auth: { userEntity: User, - methods: [ UsernameAndPassword ], /* more methods coming soon */ + methods: { + usernameAndPassword: {} + } onAuthFailedRedirectTo: "/login" } } @@ -770,13 +776,14 @@ function Home() {

Alpha

    -
  • full-stack auth (username & password)
  • +
  • full-stack auth (username & password, Google)
  • pages & routing
  • blurs the line between client & server - define your server actions and queries and call them directly in your client code (RPC)!
  • smart caching of server actions and queries (automatic cache invalidation)
  • entity (data model) definition with Prisma.io
  • ACL on frontend
  • importing NPM dependencies
  • +
  • background and scheduled jobs
@@ -784,7 +791,7 @@ function Home() {
  • ACL on backend
  • one-click deployment
  • -
  • more auth methods (Google, LinkedIn, ...)
  • +
  • more auth methods (Facebook, LinkedIn, ...)
  • tighter integration of entities with other features
  • themes and layouts
  • support for explicitly defined server API
  • diff --git a/web/static/img/integrations-google-1.jpg b/web/static/img/integrations-google-1.jpg new file mode 100644 index 0000000000..b2ff40efaf Binary files /dev/null and b/web/static/img/integrations-google-1.jpg differ diff --git a/web/static/img/integrations-google-10.jpg b/web/static/img/integrations-google-10.jpg new file mode 100644 index 0000000000..9bcc23771d Binary files /dev/null and b/web/static/img/integrations-google-10.jpg differ diff --git a/web/static/img/integrations-google-11.jpg b/web/static/img/integrations-google-11.jpg new file mode 100644 index 0000000000..0175aa0b59 Binary files /dev/null and b/web/static/img/integrations-google-11.jpg differ diff --git a/web/static/img/integrations-google-12.jpg b/web/static/img/integrations-google-12.jpg new file mode 100644 index 0000000000..95b63fd3f0 Binary files /dev/null and b/web/static/img/integrations-google-12.jpg differ diff --git a/web/static/img/integrations-google-13.jpg b/web/static/img/integrations-google-13.jpg new file mode 100644 index 0000000000..2509c820f2 Binary files /dev/null and b/web/static/img/integrations-google-13.jpg differ diff --git a/web/static/img/integrations-google-14.jpg b/web/static/img/integrations-google-14.jpg new file mode 100644 index 0000000000..286f07b0c0 Binary files /dev/null and b/web/static/img/integrations-google-14.jpg differ diff --git a/web/static/img/integrations-google-2.jpg b/web/static/img/integrations-google-2.jpg new file mode 100644 index 0000000000..c14a4d659c Binary files /dev/null and b/web/static/img/integrations-google-2.jpg differ diff --git a/web/static/img/integrations-google-3.jpg b/web/static/img/integrations-google-3.jpg new file mode 100644 index 0000000000..ff70f8503c Binary files /dev/null and b/web/static/img/integrations-google-3.jpg differ diff --git a/web/static/img/integrations-google-4.jpg b/web/static/img/integrations-google-4.jpg new file mode 100644 index 0000000000..cef73331ad Binary files /dev/null and b/web/static/img/integrations-google-4.jpg differ diff --git a/web/static/img/integrations-google-5.jpg b/web/static/img/integrations-google-5.jpg new file mode 100644 index 0000000000..18cc493842 Binary files /dev/null and b/web/static/img/integrations-google-5.jpg differ diff --git a/web/static/img/integrations-google-6.jpg b/web/static/img/integrations-google-6.jpg new file mode 100644 index 0000000000..cec27c38ab Binary files /dev/null and b/web/static/img/integrations-google-6.jpg differ diff --git a/web/static/img/integrations-google-7.jpg b/web/static/img/integrations-google-7.jpg new file mode 100644 index 0000000000..1e29c261ac Binary files /dev/null and b/web/static/img/integrations-google-7.jpg differ diff --git a/web/static/img/integrations-google-8.jpg b/web/static/img/integrations-google-8.jpg new file mode 100644 index 0000000000..9b59e7950a Binary files /dev/null and b/web/static/img/integrations-google-8.jpg differ diff --git a/web/static/img/integrations-google-9.jpg b/web/static/img/integrations-google-9.jpg new file mode 100644 index 0000000000..66b8129cb7 Binary files /dev/null and b/web/static/img/integrations-google-9.jpg differ