Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Google as an Authentication method #669

Merged
merged 102 commits into from
Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from 95 commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
5229053
POC working, hacky
Jun 7, 2022
426d4c6
signing in with google via otp token
Jun 30, 2022
bb8010f
minor update
Jun 30, 2022
5b553eb
Refactoring to use some code from shayne-google-authn
Jul 6, 2022
3422c3c
refactoring
Jul 6, 2022
105b493
starting some cleanup
Jul 7, 2022
62496be
start of refactor
Jul 7, 2022
3850d44
more cleanup
Jul 7, 2022
2ddebff
more tweaks
Jul 7, 2022
37bd0cd
more cleanup
Jul 7, 2022
ed6e80e
update scope url and values
Jul 11, 2022
97dd4a8
crossing off some todos
Jul 11, 2022
2ede79d
getting rid of route path helpers for now
Jul 11, 2022
8e8d851
wrapping token route
Jul 11, 2022
687d452
addressing some todos
Jul 12, 2022
334213a
generate OtpToken model behind the scenes
Jul 12, 2022
ec0c82e
rename auth method checks
Jul 12, 2022
e63da80
made findOrCreateUserEntity a helper
Jul 12, 2022
37626f3
bit of cleanup
Jul 12, 2022
fff55b3
swap google passport lib
Jul 12, 2022
2f4d119
remove redundant strategy
Jul 12, 2022
7299586
some cleanup
Jul 12, 2022
dc6f5da
adding patch-package
Jul 13, 2022
2b11028
include PR URL in patch for reference
Jul 13, 2022
bea4f6a
get working on Heroku
Jul 13, 2022
989fc54
doing redirect flow without needing OTP
Jul 13, 2022
40ed323
some cleanup
Jul 13, 2022
614ce1a
copy server patches over before npm install
Jul 13, 2022
3f9b8c1
updated syntax a bit
Jul 14, 2022
7026653
more refactoring
Jul 14, 2022
ac25be2
adding in some checks for auth methods
Jul 14, 2022
b5d6fb9
fix indentation
Jul 14, 2022
11bc0ae
bit of tidying up
Jul 14, 2022
b11d13a
another conditional check
Jul 14, 2022
3e1dc59
more conditional checks
Jul 14, 2022
7418870
fixed spacing
Jul 14, 2022
355ae4f
set node image name from Haskell
Jul 18, 2022
c239d58
clean oauth page
Jul 18, 2022
af2120b
update some comments
Jul 18, 2022
594a8dc
more cleanup
Jul 18, 2022
4f46a4c
bit of auth cleanup
Jul 18, 2022
2925c40
fix unit tests
Jul 18, 2022
2b5578d
update e2e
Jul 18, 2022
a66323b
start of doc updates
Jul 18, 2022
5ec6cd8
Added Google setup for auth
Jul 18, 2022
a6ce30e
minor doc updates
Jul 19, 2022
33855d7
update google button name, exposed url
Jul 20, 2022
5bcd59c
improve oauth code exchange component
Jul 20, 2022
7d0f1f5
randomize existing user passwords when signing in via google
Jul 20, 2022
bbafa81
clean up the google-related code
Jul 20, 2022
4aaa86b
pull utils up above passport
Jul 20, 2022
3c5c5a8
move getOnAuthSucceededRedirectToOrDefault into generators
Jul 20, 2022
980d811
better document node version for dockerfile
Jul 20, 2022
7c97209
clean up auth helpers
Jul 20, 2022
7c45bec
doc updates
Jul 20, 2022
1d3f9bc
update e2e
Jul 20, 2022
01a2650
rename and comment findOrCreateUserEntity as upsertUserWithRandomPass…
Jul 20, 2022
9344a57
conditionally copy over server patches
Jul 21, 2022
a6c590e
update e2e
Jul 21, 2022
fb809d4
final PR feedback
Jul 22, 2022
1c77b77
add WASP_WEB_CLIENT_URL to deployment docs
Jul 22, 2022
d853bb3
include uuid on server, remove from client since unused
Aug 15, 2022
60368c0
first round of Filip feedback
Aug 19, 2022
67d478e
fix image formatting in list for docs
Aug 19, 2022
3763bef
PR feedback
Aug 22, 2022
b82330f
initial start of targeting username approach
Aug 22, 2022
2c3db20
remove notion of first social login redirect
Aug 22, 2022
ac78c23
refactor passport initial callback
Aug 22, 2022
77c85ef
some refactoring and simplificiation
Aug 23, 2022
fa4fc62
more cleanup
Aug 23, 2022
93792bf
add another username gen function
Aug 23, 2022
0689de7
fix separator bug
Aug 23, 2022
a9d24ed
fix error message
Aug 23, 2022
ae7adc0
wrap getting user fields
Aug 24, 2022
583737e
added user version of getUserFieldsFn
Aug 24, 2022
edcf97a
Merge branch 'main' into shayne-username-jwt-authn-google
Aug 24, 2022
87ba9ec
rename email to username
Aug 24, 2022
ca17358
starting to update docs
Aug 24, 2022
8175e85
recent Filip PR feedback
Aug 30, 2022
d1409ca
Merge branch 'shayne-jwt-authn-google' into shayne-username-jwt-authn…
Aug 30, 2022
9ffcd5d
more renaming of auth method
Aug 30, 2022
1ed1262
adding validation and updating tests
Aug 30, 2022
d6d0cb5
minor updates
Aug 30, 2022
39a752c
rename association field
Aug 30, 2022
7c42c5b
add validation from user to external auth association
Aug 30, 2022
8e2fa3b
update error message
Aug 30, 2022
f7cdcd4
clean up google page
Aug 30, 2022
a5121ff
updating some docs
Aug 31, 2022
6cf621e
some cleanup
Aug 31, 2022
99ca0d3
more cleanup
Aug 31, 2022
35a8327
include patches in final exe
Aug 31, 2022
d896510
rename getUserFields
Sep 1, 2022
ede9a70
Merge branch 'main' into shayne-jwt-authn-google
Sep 1, 2022
408856a
await configFn just in case user defines an async one
Sep 1, 2022
3b97481
disable Google auth by default in todoApp
Sep 1, 2022
2f7e14e
updates after Filip call
Sep 5, 2022
55323db
more PR feedback
Sep 5, 2022
54fdb42
some small PR updates
Sep 5, 2022
da3174b
rename externalAuthAssociationEntity to externalAuthEntity
Sep 5, 2022
aad6ad2
fix warning for unused import
Sep 5, 2022
ce1dccb
clean up useEffect
Sep 6, 2022
ba9091e
remove google from bottom auth example
Sep 6, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/realworld/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ app Conduit {

auth: {
userEntity: User,
methods: [ UsernameAndPassword ],
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login"
},

Expand Down
4 changes: 3 additions & 1 deletion examples/thoughts/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ app Thoughts {
db: { system: PostgreSQL },
auth: {
userEntity: User,
methods: [ UsernameAndPassword ],
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login"
},
dependencies: [
Expand Down
4 changes: 3 additions & 1 deletion examples/tutorials/TodoApp/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ app TodoApp {

auth: {
userEntity: User,
methods: [ UsernameAndPassword ],
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login"
},

Expand Down
4 changes: 3 additions & 1 deletion examples/waspello/main.wasp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ app trello {

auth: {
userEntity: User,
methods: [ UsernameAndPassword ],
methods: {
usernameAndPassword: {}
},
onAuthFailedRedirectTo: "/login"
},

Expand Down
10 changes: 9 additions & 1 deletion waspc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved

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)
Expand Down
5 changes: 4 additions & 1 deletion waspc/data/Generator/templates/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{{={= =}=}}
FROM node:14-alpine AS node
FROM node:{= nodeMajorVersion =}-alpine AS node
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved


FROM node AS base
Expand All @@ -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 =}
sodic marked this conversation as resolved.
Show resolved Hide resolved
COPY server/package*.json ./server/
RUN cd server && npm install
{=# usingPrisma =}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import config from '../../config.js'
Martinsos marked this conversation as resolved.
Show resolved Hide resolved

export const googleSignInUrl = `${config.apiUrl}/auth/external/google/login`

export function GoogleSignInButton(props) {
return (
<a href={googleSignInUrl}>
<img alt="Sign in with Google" height={props?.height || 40} src="/images/[email protected]" />
</a>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{{={= =}=}}
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(props) {
const history = useHistory()
// 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 pathToApiServerRouteHandlingOauthRedirect = props.pathToApiServerRouteHandlingOauthRedirect

useEffect(() => {
exchangeCodeForJwtAndRedirect(history, pathToApiServerRouteHandlingOauthRedirect)
}, [history, pathToApiServerRouteHandlingOauthRedirect])

return (
<p>Completing login process...</p>
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
)
}

async function exchangeCodeForJwtAndRedirect(history, 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
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved
const apiServerUrlHandlingOauthRedirect = `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}`
sodic marked this conversation as resolved.
Show resolved Hide resolved
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved

const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect)

if (token) {
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved
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
} catch (e) {
console.error(e)
}
}
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions waspc/data/Generator/templates/react-app/src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@ import createAuthRequiredPage from "./auth/pages/createAuthRequiredPage.js"
import {= importWhat =} from "{= importFrom =}"
{=/ pagesToImport =}

{=# isExternalAuthEnabled =}
import OAuthCodeExchange from "./auth/pages/OAuthCodeExchange"
{=/ isExternalAuthEnabled =}

const router = (
<Router>
<div>
{=# routes =}
<Route exact path="{= urlPath =}" component={ {= targetComponent =} }/>
{=/ routes =}

{=# isExternalAuthEnabled =}
Martinsos marked this conversation as resolved.
Show resolved Hide resolved

{=# isGoogleAuthEnabled =}
<Route exact path="/auth/login/google">
<OAuthCodeExchange pathToApiServerRouteHandlingOauthRedirect="/auth/external/google/validateCodeForLogin" />
</Route>
{=/ isGoogleAuthEnabled =}

{=/ isExternalAuthEnabled =}
</div>
</Router>
)
Expand Down
3 changes: 2 additions & 1 deletion waspc/data/Generator/templates/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions waspc/data/Generator/templates/server/patches/oauth+0.9.15.patch
Original file line number Diff line number Diff line change
@@ -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
Martinsos marked this conversation as resolved.
Show resolved Hide resolved
+++ 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 ) {
6 changes: 5 additions & 1 deletion waspc/data/Generator/templates/server/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ 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.

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 }))
Expand Down
3 changes: 3 additions & 0 deletions waspc/data/Generator/templates/server/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,23 @@ const config = {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
frontendUrl: undefined,
{=# isAuthEnabled =}
auth: {
jwtSecret: undefined
}
{=/ isAuthEnabled =}
},
sodic marked this conversation as resolved.
Show resolved Hide resolved
development: {
frontendUrl: process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000',
{=# isAuthEnabled =}
auth: {
jwtSecret: 'DEVJWTSECRET'
}
{=/ isAuthEnabled =}
},
production: {
frontendUrl: process.env.WASP_WEB_CLIENT_URL,
{=# isAuthEnabled =}
auth: {
jwtSecret: process.env.JWT_SECRET
Expand Down
50 changes: 49 additions & 1 deletion waspc/data/Generator/templates/server/src/core/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = ['cat', 'dog', 'lion', 'rabbit', 'duck', 'pig', 'bee', 'goat', 'crab', 'fish', 'chicken', 'horse', 'llama', 'camel', 'sheep']
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved

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)}`
shayneczyzewski marked this conversation as resolved.
Show resolved Hide resolved
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have both of these functions, the one with dictionary and then also this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mainly to make it easier for the user to define a custom username strategy. It is shown in more detail for the Google profile displayName example.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can do this filtering at Prisma level, with select or smth like that, so we maybe save some data transfer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean just selecting the username col, instead of returning the whole object? Yeah, we could. At max we will have just 10 results though, so I am not sure it would be a huge gain.

const availableUsernames = potentialUsernames.filter(username => !takenUsernames.includes(username))

if (availableUsernames.length === 0) {
throw new Error('Unable to generate a unique username. Please contact Wasp.')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, this "please contact Wasp" part is interesting :D. The Wasp shall help you :D!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was on the fence, but if that happens, we will know they are prob in the multiple million user range lol. Then we'd make the number of candidate usernames configurable/larger and make the random number suffix configurable/larger too. :D

}

return availableUsernames[0]
}

export default auth
10 changes: 9 additions & 1 deletion waspc/data/Generator/templates/server/src/routes/auth/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
{{={= =}=}}
import express from 'express'

import auth from '../../core/auth.js'
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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
Loading