Skip to content

Commit

Permalink
Feat/runtime config (#51)
Browse files Browse the repository at this point in the history
* feat: Initial attempt at using `caddy`

* chore: Add caddy support to devcontainer

* chore: Fix refs to Node container in `Makefile`

* feat: Use relative API paths and take host from env

* chore: Need `caddy` in the devcontainer

* feat: Serve up app via Caddy and proxy the API

* feat: Add `/auth-config` endpoint to `Caddyfile`

* feat: Add util function for retrieving auth config

* feat: Fetch auth config during app setup and `provide` it

* feat: Use auth config in `useAuth` composable

* test: `useAuth` without auth config provided

* feat: Add custom entrypoint to ensure we have the required environment variables

* chore: Use the Docker layer cache when building

* chore: Ensure we're actually using our Dockerfile (whoops)

* chore: Need a `CMD`

* chore: No need to persist `/config`

* chore: Make `eslint` ignore build directories

* feat: Support local dev via Vue and Caddy

* feat: Allow for auth config in Vue dev

* feat: Add `reload-caddy-dev` `Makefile` command for convenience
  • Loading branch information
howamith authored Nov 24, 2024
1 parent 85f8443 commit 724feac
Show file tree
Hide file tree
Showing 21 changed files with 388 additions and 145 deletions.
4 changes: 3 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"name": "CronMon App Dev Container",
"build": {
"dockerfile": "../Dockerfile",
"context": ".."
"context": "..",
"target": "builder"
},
"runArgs": [
"--name",
Expand Down Expand Up @@ -38,6 +39,7 @@
"ms-azuretools.vscode-docker",
"tamasfe.even-better-toml",
"Vue.volar",
"matthewpi.caddyfile-support",
"ms-vscode.makefile-tools",
"tamasfe.even-better-toml"
]
Expand Down
2 changes: 1 addition & 1 deletion .devcontainer/on-create.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#! /usr/bin/bash

# Need bash-completion for bash completion, curl to download startship.rs, and ssh for Git.
apt-get update && apt-get install -y bash-completion curl ssh
apt-get update && apt-get install -y bash-completion caddy curl ssh

# Setup bash completion
echo "source /etc/bash_completion" >> ~/.bashrc
Expand Down
13 changes: 12 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
FROM public.ecr.aws/docker/library/node:20.12-slim
FROM public.ecr.aws/docker/library/node:20.12-slim as builder

WORKDIR /usr/cron-mon/app

RUN npm install -g npm@latest

COPY ./app .
ENV PATH /usr/cron-mon/app/node_modules/.bin:$PATH

RUN npm install && npm run build

FROM public.ecr.aws/docker/library/caddy:2.9

COPY ./entrypoint.sh /entrypoint.sh
COPY --from=builder /usr/cron-mon/app/dist /srv
COPY ./app/Caddyfile /etc/caddy/Caddyfile

ENTRYPOINT ["/entrypoint.sh"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
23 changes: 16 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
install: build-containers npm-install
install: build-containers

build-containers:
docker compose build --no-cache
docker compose build

npm-install:
docker compose run --rm app bash -c 'npm install'
docker compose run --rm dev bash -c 'npm install'

build-app:
docker compose run --rm --no-deps app bash -c 'npm run build'
docker compose run --rm --no-deps dev bash -c 'npm run build'

run:
docker compose up app
docker compose up caddy

run-caddy-dev:
docker compose up caddy-dev

run-vue-dev:
docker compose up vue-dev

reload-caddy-dev:
docker compose exec caddy-dev caddy reload --config /etc/caddy/Caddyfile

test: static-test unit-test

unit-test:
docker compose run --rm --no-deps app bash -c 'npm run test:unit'
docker compose run --rm --no-deps vue-dev bash -c 'npm run test:unit'

static-test:
docker compose run --rm --no-deps app bash -c 'npm run lint && npm run type-check'
docker compose run --rm --no-deps vue-dev bash -c 'npm run lint && npm run type-check'
1 change: 1 addition & 0 deletions app/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_HOST=
4 changes: 4 additions & 0 deletions app/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_API_HOST=$API_HOST
VITE_KEYCLOAK_URL=$KEYCLOAK_URL
VITE_KEYCLOAK_REALM=$KEYCLOAK_REALM
VITE_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID
3 changes: 3 additions & 0 deletions app/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
VITE_KEYCLOAK_URL=http://keycloak
VITE_KEYCLOAK_REALM=keycloak-realm
VITE_KEYCLOAK_CLIENT_ID=keycloak-client-id
3 changes: 2 additions & 1 deletion app/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ module.exports = {
"ignoreRestSiblings": true
}
]
}
},
ignorePatterns: ["dist", "node_modules"]
}
48 changes: 48 additions & 0 deletions app/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
:3000 {
# Access logs
log {
format filter {
# No need for any headers to be in any of the logs
# (might be useful to be able to switch these on though?)
request>headers delete
resp_headers delete

wrap json {
message_key message
time_key time
time_format rfc3339
}
}
}

encode gzip zstd

# Provide dynamic Keycloak config.
handle /auth-config {
header {
Content-Type application/json
}
respond 200 {
# Caddy docs say we can use heredocs here, but it doesn't work, and the caddy formatter
# doesn't like the newlines in the string, so we have to do it like this.
body ` {
"url": "{$KEYCLOAK_URL}",
"realm": "{$KEYCLOAK_REALM}",
"client": "{$KEYCLOAK_CLIENT_ID}"
}
`
}
}

# Proxy the Cron Mon API.
handle /api/* {
reverse_proxy {$API_HOST}
}

# Serve up the frontend application.
handle {
root * /srv
try_files {path}.html {path} /index.html
file_server
}
}
93 changes: 38 additions & 55 deletions app/src/composables/__test__/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ const TestComponent = defineComponent({
}
})

function mountTestComponent(protectedRoutes: string[]) {
return mount(TestComponent, {
props: {
protectedRoutes
},
global: {
provide: {
$authConfig: {
url: 'http://keycloak',
realm: 'cron-mon-io',
client: 'cron-mon'
}
}
}
})
}

describe('useAuth composable when user not previously authenticated', () => {
interface MockKeycloak {
init: () => Promise<{ authenticated: boolean }>
Expand Down Expand Up @@ -99,11 +116,7 @@ describe('useAuth composable when user not previously authenticated', () => {
it('does not log in user when on unprotected route', async () => {
vi.useFakeTimers()

const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/foo']
}
})
const wrapper = mountTestComponent(['/foo'])
await flushPromises()

expect(mockKeycloak.init).toHaveBeenCalled()
Expand All @@ -119,11 +132,7 @@ describe('useAuth composable when user not previously authenticated', () => {
})

it('logs in user when on protected route', async () => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

expect(mockKeycloak.init).toHaveBeenCalled()
Expand All @@ -136,11 +145,7 @@ describe('useAuth composable when user not previously authenticated', () => {
})

it('logs in user when navigating to protected route', async () => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/foo']
}
})
const wrapper = mountTestComponent(['/foo'])
await flushPromises()

expect(mockKeycloak.init).toHaveBeenCalled()
Expand All @@ -163,11 +168,7 @@ describe('useAuth composable when user not previously authenticated', () => {
it('handles login failure', async () => {
mockKeycloak.login = vi.fn().mockRejectedValue(new Error('Login failed'))

const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

expect(mockKeycloak.init).toHaveBeenCalled()
Expand All @@ -176,11 +177,7 @@ describe('useAuth composable when user not previously authenticated', () => {
})

it('isReady when immediately ready', async () => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

await wrapper.vm.isReady()
Expand All @@ -195,11 +192,7 @@ describe('useAuth composable when user not previously authenticated', () => {
},
async () => {
vi.useFakeTimers()
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

const prom = wrapper.vm.isReady()
Expand All @@ -211,6 +204,16 @@ describe('useAuth composable when user not previously authenticated', () => {
}
)
})

it('throws error when no auth config provided', async () => {
await expect(() => {
mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
}).toThrowError('AuthConfig not provided')
})
})

describe('useAuth composable when user is previously authenticated', () => {
Expand Down Expand Up @@ -253,11 +256,7 @@ describe('useAuth composable when user is previously authenticated', () => {
})

it('authenticates user without making them login again', async () => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])

expect(wrapper.vm.user).toBeNull()

Expand All @@ -275,11 +274,7 @@ describe('useAuth composable when user is previously authenticated', () => {
it('refreshes token', async () => {
vi.useFakeTimers()

const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

const initialToken = wrapper.vm.getToken()
Expand All @@ -304,11 +299,7 @@ describe('useAuth composable when user is previously authenticated', () => {
async () => {
vi.useFakeTimers()

const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

const initialToken = wrapper.vm.getToken()
Expand All @@ -333,11 +324,7 @@ describe('useAuth composable when user is previously authenticated', () => {
{ protectedRoutes: ['/protected'], redirectUri: 'http://cron-mon.io' },
{ protectedRoutes: ['/foo'], redirectUri: 'http://cron-mon.io/protected' }
])('logs out user', async ({ protectedRoutes, redirectUri }) => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes
}
})
const wrapper = mountTestComponent(protectedRoutes)
await flushPromises()

await wrapper.vm.logout()
Expand All @@ -348,11 +335,7 @@ describe('useAuth composable when user is previously authenticated', () => {
})

it('opens account management', async () => {
const wrapper = mount(TestComponent, {
props: {
protectedRoutes: ['/protected']
}
})
const wrapper = mountTestComponent(['/protected'])
await flushPromises()

await wrapper.vm.openAccountManagement()
Expand Down
23 changes: 17 additions & 6 deletions app/src/composables/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Keycloak from 'keycloak-js'
import { ref, type Ref, type ComputedRef, onMounted, computed } from 'vue'
import { ref, type Ref, type ComputedRef, onMounted, computed, inject } from 'vue'
import { useRouter, useRoute, type RouteLocationNormalizedGeneric } from 'vue-router'

import type { AuthConfig } from '@/utils/config'

export interface AuthenticatedUser {
firstName: string
lastName: string
Expand All @@ -18,11 +20,7 @@ export interface Auth {
}

export function useAuth(protectedRoutes: string[]): Auth {
const keycloak = new Keycloak({
url: 'http://127.0.0.1:8080',
realm: 'cron-mon-io',
clientId: 'cron-mon'
})
const keycloak = setupKeycloak()

let ready = false
const router = useRouter()
Expand Down Expand Up @@ -88,6 +86,19 @@ export function useAuth(protectedRoutes: string[]): Auth {
: null
}

function setupKeycloak(): Keycloak {
console.log('Getting authConfig...')
const authConfig = inject<AuthConfig>('$authConfig')
if (!authConfig) {
throw new Error('AuthConfig not provided')
}
return new Keycloak({
url: authConfig.url,
realm: authConfig.realm,
clientId: authConfig.client
})
}

return {
isAuthenticated: computed(() => keycloak.authenticated),
user,
Expand Down
Loading

0 comments on commit 724feac

Please sign in to comment.