feat: Add GitHub OAuth (#1050)

* Initial oauth

* Add Github authentication

* Add AuthMethods endpoint

* Add frontend

* Rename basic authentication to password

* Add flags for configuring GitHub auth

* Remove name from API keys

* Fix authmethods in test

* Add stories and display auth methods error
This commit is contained in:
Kyle Carberry
2022-04-23 22:58:57 +00:00
committed by GitHub
parent 3976994781
commit 7496c3da81
41 changed files with 1251 additions and 422 deletions
+6
View File
@@ -2,6 +2,7 @@ import axios, { AxiosRequestHeaders } from "axios"
import { mutate } from "swr"
import { MockPager, MockUser, MockUser2 } from "../testHelpers/entities"
import * as Types from "./types"
import * as TypesGen from "./typesGenerated"
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
"Content-Type": "application/json",
@@ -65,6 +66,11 @@ export const getUser = async (): Promise<Types.UserResponse> => {
return response.data
}
export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
const response = await axios.get<TypesGen.AuthMethods>("/api/v2/users/authmethods")
return response.data
}
export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
const response = await axios.post<Types.APIKeyResponse>("/api/v2/users/me/keys")
return response.data
+6
View File
@@ -132,6 +132,12 @@ export interface CreateWorkspaceRequest {
readonly name: string
}
// From codersdk/users.go:96:6.
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
}
// From codersdk/workspaceagents.go:31:6.
export interface GoogleInstanceIdentityToken {
readonly json_web_token: string
@@ -24,7 +24,26 @@ SignedOut.args = {
}
export const Loading = Template.bind({})
Loading.args = { ...SignedOut.args, isLoading: true }
Loading.args = {
...SignedOut.args,
isLoading: true,
authMethods: {
github: true,
password: true,
},
}
export const WithError = Template.bind({})
WithError.args = { ...SignedOut.args, authErrorMessage: "Email or password was invalid" }
export const WithLoginError = Template.bind({})
WithLoginError.args = { ...SignedOut.args, authErrorMessage: "Email or password was invalid" }
export const WithAuthMethodsError = Template.bind({})
WithAuthMethodsError.args = { ...SignedOut.args, methodsErrorMessage: "Failed to fetch auth methods" }
export const WithGithub = Template.bind({})
WithGithub.args = {
...SignedOut.args,
authMethods: {
password: true,
github: true,
},
}
+27 -3
View File
@@ -1,9 +1,12 @@
import Button from "@material-ui/core/Button"
import FormHelperText from "@material-ui/core/FormHelperText"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { AuthMethods } from "../../api/typesGenerated"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { Welcome } from "../Welcome/Welcome"
import { LoadingButton } from "./../LoadingButton/LoadingButton"
@@ -24,7 +27,9 @@ export const Language = {
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
authErrorMessage: "Incorrect email or password.",
signIn: "Sign In",
methodsErrorMessage: "Unable to fetch auth methods.",
passwordSignIn: "Sign In",
githubSignIn: "GitHub",
}
const validationSchema = Yup.object({
@@ -49,10 +54,18 @@ const useStyles = makeStyles((theme) => ({
export interface SignInFormProps {
isLoading: boolean
authErrorMessage?: string
methodsErrorMessage?: string
authMethods?: AuthMethods
onSubmit: ({ email, password }: { email: string; password: string }) => Promise<void>
}
export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMessage, onSubmit }) => {
export const SignInForm: React.FC<SignInFormProps> = ({
authMethods,
isLoading,
authErrorMessage,
methodsErrorMessage,
onSubmit,
}) => {
const styles = useStyles()
const form: FormikContextType<BuiltInAuthFormValues> = useFormik<BuiltInAuthFormValues>({
@@ -76,6 +89,7 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
className={styles.loginTextField}
fullWidth
label={Language.emailLabel}
type="email"
variant="outlined"
/>
<TextField
@@ -89,12 +103,22 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
variant="outlined"
/>
{authErrorMessage && <FormHelperText error>{Language.authErrorMessage}</FormHelperText>}
{methodsErrorMessage && <FormHelperText error>{Language.methodsErrorMessage}</FormHelperText>}
<div className={styles.submitBtn}>
<LoadingButton color="primary" loading={isLoading} fullWidth type="submit" variant="contained">
{isLoading ? "" : Language.signIn}
{isLoading ? "" : Language.passwordSignIn}
</LoadingButton>
</div>
</form>
{authMethods?.github && (
<div className={styles.submitBtn}>
<Link href="/api/v2/users/oauth2/github/callback">
<Button color="primary" disabled={isLoading} fullWidth type="submit" variant="contained">
{Language.githubSignIn}
</Button>
</Link>
</div>
)}
</>
)
}
+41 -2
View File
@@ -23,7 +23,7 @@ describe("LoginPage", () => {
render(<LoginPage />)
// Then
await screen.findByText(Language.signIn)
await screen.findByText(Language.passwordSignIn)
})
it("shows an error message if SignIn fails", async () => {
@@ -42,7 +42,7 @@ describe("LoginPage", () => {
await userEvent.type(email, "test@coder.com")
await userEvent.type(password, "password")
// Click sign-in
const signInButton = await screen.findByText(Language.signIn)
const signInButton = await screen.findByText(Language.passwordSignIn)
act(() => signInButton.click())
// Then
@@ -50,4 +50,43 @@ describe("LoginPage", () => {
expect(errorMessage).toBeDefined()
expect(history.location.pathname).toEqual("/login")
})
it("shows an error if fetching auth methods fails", async () => {
// Given
server.use(
// Make login fail
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: "nope" }))
}),
)
// When
render(<LoginPage />)
// Then
const errorMessage = await screen.findByText(Language.methodsErrorMessage)
expect(errorMessage).toBeDefined()
})
it("shows github authentication when enabled", async () => {
// Given
server.use(
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
password: true,
github: true,
}),
)
}),
)
// When
render(<LoginPage />)
// Then
await screen.findByText(Language.passwordSignIn)
await screen.findByText(Language.githubSignIn)
})
})
+10 -1
View File
@@ -35,6 +35,9 @@ export const LoginPage: React.FC = () => {
const isLoading = authState.hasTag("loading")
const redirectTo = retrieveRedirect(location.search)
const authErrorMessage = authState.context.authError ? (authState.context.authError as Error).message : undefined
const getMethodsError = authState.context.getMethodsError
? (authState.context.getMethodsError as Error).message
: undefined
const onSubmit = async ({ email, password }: { email: string; password: string }) => {
authSend({ type: "SIGN_IN", email, password })
@@ -47,7 +50,13 @@ export const LoginPage: React.FC = () => {
<div className={styles.root}>
<div className={styles.layout}>
<div className={styles.container}>
<SignInForm isLoading={isLoading} authErrorMessage={authErrorMessage} onSubmit={onSubmit} />
<SignInForm
authMethods={authState.context.methods}
isLoading={isLoading}
authErrorMessage={authErrorMessage}
methodsErrorMessage={getMethodsError}
onSubmit={onSubmit}
/>
</div>
<Footer />
+6
View File
@@ -9,6 +9,7 @@ import {
Workspace,
WorkspaceAutostartRequest,
} from "../api/types"
import { AuthMethods } from "../api/typesGenerated"
export const MockSessionToken = { session_token: "my-session-token" }
@@ -97,3 +98,8 @@ export const MockUserAgent: UserAgent = {
ip_address: "11.22.33.44",
os: "Windows 10",
}
export const MockAuthMethods: AuthMethods = {
password: true,
github: false,
}
+3
View File
@@ -42,6 +42,9 @@ export const handlers = [
rest.get("/api/v2/users/me/keys", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockAPIKey))
}),
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockAuthMethods))
}),
// workspaces
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
+47 -3
View File
@@ -1,16 +1,20 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api"
import * as Types from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
successProfileUpdate: "Updated preferences.",
}
export interface AuthContext {
getUserError?: Error | unknown
getMethodsError?: Error | unknown
authError?: Error | unknown
updateProfileError?: Error | unknown
me?: Types.UserResponse
methods?: TypesGen.AuthMethods
}
export type AuthEvent =
@@ -19,10 +23,17 @@ export type AuthEvent =
| { type: "UPDATE_PROFILE"; data: Types.UpdateProfileRequest }
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogCMABgCcAFkLyZAVilS5GgEwAOAMwA2ADQgAnogDsa5YcOXD2y7oWH92-QF9PptFlz4RKQUJORQDOS0ECJEoQBufADWQWTkEWL8gsKiSBKI7kraqpZSuh7aUvpy6vqmFghlcoT6qgb6GsUlctrevhg4eATEqaHhkWAAThN8E4Q8ADb4AGYzALbDFOm5mSRCImKSCLKKynJqGlpSekZ1iIaltvaOzq4FvSB+A4GEMOhCYQBVWCTKIxQjxJJEX4AWTAGQEu2yB0QrikhG0Mn0+l0ulUWJkhlatXMKKqynkCn0lOscnsUnenwCQ1+-ygQJBk2mswWyzWPzA6Fh8Ky+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmsQgNtoAIACgARACCABUAKJsR0AJSoADEGAAZT3CxGi0CHGTKmSG-yDE2UCDmniW61EVA8CD4UaO9P26KUcHkBLJQiMxMbZOpguZ7O5sL5vhWm0ICEAY1zIgA2jIALrhvY5KPSKSWNWKWQeaUKSXj5UY3SEUrdKqExy08fxr5DYI18gWlsZwhZnOs5utsC0TkzOaLdArCbrSvffdmw9p48208Ni919tSz4Lthz7QdtgRYdkQaLRCF0SxtAUcdpQnRxdGVXV9EIKdFBkGRdDkSxFGKHdjWrD96GYdgqABd0hyRMVpCxdFaSxVQZEsIxx0sSxlUI9Eql1DUJQnRQ5FIqt91GGh0FBYsIXLfcZPoyM8gQdwsIQuRdEMSlSm1DxlR1Zc1AIhDdJwrwfA+I1JJGMIZJvKY7x5R8+SUjAVJHNSijVdwCIUdQriJfReJJBAihkZpLAUJDikigwJW8azyD4CA4DEV891SahPIgkVvMOdRDEINxqTUbFCW05UJywhQDFUNd2gxQxxOsrKk1GLZeEghjRyOfUoqkPEOOlQj2mVVrtDgjUEPYkpcT0CTvhZUZ2QmLzoLnZVdA1OCiS0TjcU0VRluy00U0-OtwTtIhUs9ZyNvyiNCrHHi4InPCFE4wktQwvbQtiloWu0jFLDOpMPyPK8bp-W8np6groI0d74PYmRvqMdilXC1wSvYxRYsIwbrHB9rbLfHLLuhk8SFuzbGKOYasLRr6fux5UZWi2KCclXarL6BNKYu2tv3rc88zrBn+pxTTGsqULp2xKRlRO0qCJnDdJXuMnBd3SHqa-K9pbUgBaRDlVNzjyTwjpOM+1QDXJoXzoPE3DlN+rLakVwba1dpvvYjQIeraS8sRl7oPcQgicQicihxGQfaM9jlFaWdSgJDR6Wd-X3cQZdY8DhPdCThRLY8dUOPsBwDE4wjdGSzwgA */
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogDsABgCshOQA4AzABY5ARgBMUgJxrtcgDQgAnok0zNSwkpkrNKvQDY3aqS6kBfb6bRYuPhEpBQk5FAM5LQQIkThAG58ANYhZORRYvyCwqJIEohyMlKE2mrFKo7aLq5SJuaILtq2mjZqrboy2s4+fiABOHgExOnhkdFgAE6TfJOEPAA2+ABmswC2IxSZ+dkkQiJikgiyCsrqWroGRqYWCHoq2oQyDpouXa1qGmq+-hiDwYQYOghBEAKqwKYxOKERIpIhAgCyYCyAj2uUOiF0mieTik3UqMhqSleN0QhgUOhUbzUKhk9jKch+-T+QWGQJBUHBkKmMzmixW60BYHQSJROQO+SOUmxVKUniUcjkUgVLjlpOOCqecj0LyKmmVeiUTIGrPhwo5SKwfAgsChlBh5CSqSFIuFmGt8B2qP2eVARxUeNKCo02k0cllTRc6qUalsCtU731DikvV+gSGZuBY0t7pttB5s3mS3Qq0mG0Rbo9YrREr9iHUMkIUnKHSklQVKnqtz0BkImjUbmeShcVmejL6Jozm0oECi8xmyxIC3iEGXtFBAAUACIAQQAKgBRNgbgBKVAAYgwADIH6s+jEIOV6Qgj1oxnTBkfq7qNlxyT4aC4saaPcVLGiyU6hDOc48AuS5EKgPAQPgYwbnBa6xPasLOpOAJQZAMHoQhSEoREaF8Iuy4ILCADGKEiAA2jIAC6d7opKiBPi+rRtB+-5fg0CDqM+YafG8+jWLI2jgemeHpAR5DzhR8GEIhyEcuRlFgPm0yFvyJaCrhwz4bOimwcpy6qSRGlEdRjp8HRPpMaxXrir6BSPvo3Fvu0zT8Zo6oDmoTb-p8cjaFcsjqDJ-zGfJpn0Mw7BUKCe5sbWHm6CU2jWKGnhaFSeLqv2jZKMOehOE49i0v+MWmtOYw0OgdrxPZzpQU16XuUcAC0-aPGGSgVQOTS4ko2jfjGhC0gB1WeDIBguHVkGjBETU6byRYCmW06da5NbdZiKalLl+p-k4XgTYJNhxuVNKGCB77fEy5DWnAYhGWkFDUBgXUPoqLiKOoxIqGVejNKoxXPCUMgjZ8zj6sORoThBclhBE2y8N67F1o+xIvhoejavqEX-gFgl6IGFWOC2bzWNqy0AuyYxcpMf0cQghjqn+JT6GJeJynIqrI2msWZhalY2uzuN9dojwpn+epDvqHjRkUL7KBDEljiLzKyXF32mUpWkwquRCvQeuls-t94c606s8c0A5C00UjqqDja5cUXQRXiDiMwb0FmURpuWQW1tY25D7242jsxn+bi6IFhh2K4qjKP+fsqAHX1B8bKkkGb0seR0gNx87idu4JtIwzoxTdELzbheOov1SZhEWcR6moURxdHCOgPhsBoNDRDKju84hCfHS4ZAXPqg59OCn58ufeFODQPD2DY-FUqGsASmdShgvKP67nClrwgQu2EPIPb2V4+CVNf7w5TOphs22en2LDVrb9Ns4w8j1coU8iafDKJVWQagDDqmOsOKBVhXDgweNJb+ppL59TcH2ZQw1E5jSurcYK8CHCODfE0B4vRfBAA */
createMachine(
{
context: { me: undefined, getUserError: undefined, authError: undefined, updateProfileError: undefined },
context: {
me: undefined,
getUserError: undefined,
authError: undefined,
updateProfileError: undefined,
methods: undefined,
getMethodsError: undefined,
},
tsTypes: {} as import("./authXService.typegen").Typegen0,
schema: {
context: {} as AuthContext,
@@ -31,6 +42,9 @@ export const authMachine =
getMe: {
data: Types.UserResponse
}
getMethods: {
data: TypesGen.AuthMethods
}
signIn: {
data: Types.LoginResponse
}
@@ -81,6 +95,25 @@ export const authMachine =
onError: [
{
actions: "assignGetUserError",
target: "gettingMethods",
},
],
},
tags: "loading",
},
gettingMethods: {
invoke: {
src: "getMethods",
id: "getMethods",
onDone: [
{
actions: ["assignMethods", "clearGetMethodsError"],
target: "signedOut",
},
],
onError: [
{
actions: "assignGetMethodsError",
target: "signedOut",
},
],
@@ -139,7 +172,7 @@ export const authMachine =
onDone: [
{
actions: ["unassignMe", "clearAuthError"],
target: "signedOut",
target: "gettingMethods",
},
],
onError: [
@@ -160,6 +193,7 @@ export const authMachine =
},
signOut: API.logout,
getMe: API.getUser,
getMethods: API.getAuthMethods,
updateProfile: async (context, event) => {
if (!context.me) {
throw new Error("No current user found")
@@ -176,6 +210,16 @@ export const authMachine =
...context,
me: undefined,
})),
assignMethods: assign({
methods: (_, event) => event.data,
}),
assignGetMethodsError: assign({
getMethodsError: (_, event) => event.data,
}),
clearGetMethodsError: assign((context: AuthContext) => ({
...context,
getMethodsError: undefined,
})),
assignGetUserError: assign({
getUserError: (_, event) => event.data,
}),