Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions services/api/.env
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ APP_LOGO_URL=

API_URL=http://localhost:2300

# How to authenticate (password|link|code)
AUTH_TYPE=password
# Link/code send by (email|sms)
AUTH_CHANNEL=email
# Allow passkey sign-in
AUTH_PASSKEY=true

DEFAULT_TIME_ZONE=America/New_York

# Uploads (local|gcs)
Expand Down
17 changes: 17 additions & 0 deletions services/api/src/routes/auth/passkey-disabled.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
vi.hoisted(() => {
process.env.AUTH_PASSKEY = 'false';
});

import { request } from '../../utils/testing/index.js';

describe('/1/auth/passkey (disabled)', () => {
it('should reject passkey routes', async () => {
const response = await request('POST', '/1/auth/passkey/generate-login', {});
expect(response).toHaveStatus(403);
});

it('should report passkey as disabled in meta', async () => {
const response = await request('GET', '/1/meta', {}, {});
expect(response.body.data.auth.passkey).toBe(false);
});
});
7 changes: 7 additions & 0 deletions services/api/src/routes/auth/passkey.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Router from '@koa/router';
import yd from '@bedrockio/yada';
import config from '@bedrockio/config';

import { validateBody } from '../../utils/middleware/validate.js';
import { authenticate } from '../../utils/middleware/authenticate.js';
Expand All @@ -18,6 +19,12 @@ import {
const router = new Router();

router
.use(async (ctx, next) => {
if (!config.get('AUTH_PASSKEY', 'boolean')) {
ctx.throw(403, 'Passkey authentication is disabled.');
}
await next();
})
.post('/generate-login', async (ctx) => {
try {
ctx.body = {
Expand Down
6 changes: 6 additions & 0 deletions services/api/src/routes/meta.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Router from '@koa/router';
import config from '@bedrockio/config';
import types from '../lib/notifications/types.js';

import roles from '../roles.json' with { type: 'json' };
Expand All @@ -10,6 +11,11 @@ router.get('/', async (ctx) => {
data: {
roles,
notifications: types,
auth: {
type: config.get('AUTH_TYPE'),
channel: config.get('AUTH_CHANNEL'),
passkey: config.get('AUTH_PASSKEY', 'boolean'),
},
},
};
});
Expand Down
15 changes: 15 additions & 0 deletions services/api/src/routes/meta.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ describe('/1/meta', () => {
type: 'product-updated',
},
],
auth: {
type: 'password',
channel: 'email',
passkey: true,
},
});
});

it('should get app meta without authentication', async () => {
const response = await request('GET', '/1/meta', {}, {});
expect(response).toHaveStatus(200);
expect(response.body.data.auth).toEqual({
type: 'password',
channel: 'email',
passkey: true,
});
});
});
Expand Down
7 changes: 0 additions & 7 deletions services/web/.env
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ SENTRY_DSN=
# Google Tag Manager Analytics
GTM_CONTAINER_ID=

# How to authenticate (password|link|code)
AUTH_TYPE=password
# Link/code send by (email|sms)
AUTH_CHANNEL=email
# Allow passkey?
AUTH_PASSKEY=

# Google Maps and Address Lookup
# https://console.cloud.google.com/apis/library/maps-backend.googleapis.com
# https://console.cloud.google.com/apis/library/places-backend.googleapis.com
Expand Down
5 changes: 4 additions & 1 deletion services/web/src/components/Auth/Federated.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useSession } from 'stores/session';

import { canShowAppleSignin } from 'utils/auth/apple';
import { canShowGoogleSignin } from 'utils/auth/google';
import { canShowPasskey } from 'utils/auth/passkey';
Expand All @@ -8,12 +10,13 @@ import PasskeyButton from './PasskeyButton';

export default function Federated(props) {
const { type } = props;
const { meta } = useSession();

const isSignup = type === 'signup';

const showApple = canShowAppleSignin();
const showGoogle = canShowGoogleSignin();
const showPasskey = !isSignup && canShowPasskey();
const showPasskey = !isSignup && canShowPasskey(meta);

if (!showApple && !showGoogle && !showPasskey) {
return null;
Expand Down
10 changes: 0 additions & 10 deletions services/web/src/components/Auth/OptionalPassword.js

This file was deleted.

56 changes: 31 additions & 25 deletions services/web/src/screens/Auth/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,19 @@ import { PasswordInput } from '@/components/ui/password-input';
import { Separator } from '@/components/ui/separator';

import { request } from 'utils/api';
import { AUTH_CHANNEL, AUTH_TYPE } from 'utils/env';
import { formatPhone, normalizePhone } from 'utils/phone';

// The SMS channel delivers to a phone, so the login screen identifies by phone
// rather than email. Password login never uses a channel.
const USE_PHONE = AUTH_TYPE !== 'password' && AUTH_CHANNEL === 'sms';
function usesPhone(auth) {
return auth.type !== 'password' && auth.channel === 'sms';
}

function login(values) {
if (AUTH_TYPE === 'password') {
function login(values, auth) {
if (auth.type === 'password') {
return loginPassword(values);
} else {
return loginOtp(values);
return loginOtp(values, auth);
}
}

Expand All @@ -51,37 +52,42 @@ async function loginPassword(body) {
});
}

async function loginOtp(body) {
async function loginOtp(body, auth) {
return await request({
method: 'POST',
path: `/1/auth/otp/send`,
body: {
...(USE_PHONE ? { phone: body.phone } : { email: body.email }),
type: AUTH_TYPE,
channel: AUTH_CHANNEL,
...(usesPhone(auth) ? { phone: body.phone } : { email: body.email }),
type: auth.type,
channel: auth.channel,
},
});
}

const schema = z.object({
email: USE_PHONE
? z.string().optional()
: z.string().min(1, 'Email is required').email('Enter a valid email'),
phone: USE_PHONE
? z.string().min(1, 'Phone is required')
: z.string().optional(),
password:
AUTH_TYPE === 'password'
? z.string().min(1, 'Password is required')
function getSchema(auth) {
const usePhone = usesPhone(auth);
return z.object({
email: usePhone
? z.string().optional()
: z.string().min(1, 'Email is required').email('Enter a valid email'),
phone: usePhone
? z.string().min(1, 'Phone is required')
: z.string().optional(),
});
password:
auth.type === 'password'
? z.string().min(1, 'Password is required')
: z.string().optional(),
});
}

export default function PasswordLogin() {
const navigate = useNavigate();
const { authenticate } = useSession();
const { authenticate, meta } = useSession();
const { auth } = meta;
const usePhone = usesPhone(auth);

const form = useForm({
resolver: zodResolver(schema),
resolver: zodResolver(getSchema(auth)),
defaultValues: {
email: '',
phone: '',
Expand All @@ -104,7 +110,7 @@ export default function PasswordLogin() {
try {
setError(null);

const { data } = await login(values);
const { data } = await login(values, auth);
const { token, challenge } = data;

if (token) {
Expand Down Expand Up @@ -132,7 +138,7 @@ export default function PasswordLogin() {
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4">
{USE_PHONE ? (
{usePhone ? (
<FormField
control={form.control}
name="phone"
Expand Down Expand Up @@ -176,7 +182,7 @@ export default function PasswordLogin() {
)}
/>
)}
{AUTH_TYPE === 'password' && (
{auth.type === 'password' && (
<FormField
control={form.control}
name="password"
Expand Down
45 changes: 23 additions & 22 deletions services/web/src/screens/Auth/Signup.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,33 @@ import { PasswordInput } from '@/components/ui/password-input';
import { Separator } from '@/components/ui/separator';

import { useRequest } from 'utils/api';
import { AUTH_CHANNEL, AUTH_TYPE } from 'utils/env';
import { formatPhone, normalizePhone } from 'utils/phone';

// The signup route requires the identifier matching the channel. A password
// account also needs an email, as password login only accepts one.
const NEEDS_EMAIL = AUTH_CHANNEL === 'email' || AUTH_TYPE === 'password';
const NEEDS_PHONE = AUTH_CHANNEL === 'sms';

const schema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: NEEDS_EMAIL
? z.string().min(1, 'Email is required').email('Invalid email')
: z.union([z.literal(''), z.string().email('Invalid email')]).optional(),
phone: NEEDS_PHONE
? z.string().min(1, 'Phone is required')
: z.string().optional(),
password:
AUTH_TYPE === 'password'
? z.string().min(1, 'Password is required')
function getSchema(auth) {
const needsEmail = auth.channel === 'email' || auth.type === 'password';
const needsPhone = auth.channel === 'sms';
return z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: needsEmail
? z.string().min(1, 'Email is required').email('Invalid email')
: z.union([z.literal(''), z.string().email('Invalid email')]).optional(),
phone: needsPhone
? z.string().min(1, 'Phone is required')
: z.string().optional(),
});
password:
auth.type === 'password'
? z.string().min(1, 'Password is required')
: z.string().optional(),
});
}

export default function SignupPassword() {
const navigate = useNavigate();
const { authenticate } = useSession();
const { authenticate, meta } = useSession();
const { auth } = meta;
const [error, setError] = useState(null);

const signupRequest = useRequest({
Expand All @@ -72,7 +73,7 @@ export default function SignupPassword() {
});

const form = useForm({
resolver: zodResolver(schema),
resolver: zodResolver(getSchema(auth)),
defaultValues: {
firstName: '',
lastName: '',
Expand All @@ -92,8 +93,8 @@ export default function SignupPassword() {
await signupRequest.request({
body: {
...values,
type: AUTH_TYPE,
channel: AUTH_CHANNEL,
type: auth.type,
channel: auth.channel,
},
});
}
Expand Down Expand Up @@ -187,7 +188,7 @@ export default function SignupPassword() {
</FormItem>
)}
/>
{AUTH_TYPE === 'password' && (
{auth.type === 'password' && (
<FormField
control={form.control}
name="password"
Expand Down
Loading
Loading