mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user