Skip to content

Commit

Permalink
Merge pull request #359 from vevcom/refactor/omegaID3
Browse files Browse the repository at this point in the history
Refactor/omega id
  • Loading branch information
theodorklauritzen authored Nov 7, 2024
2 parents 749c4ae + b70b9fa commit 4cd1517
Show file tree
Hide file tree
Showing 26 changed files with 199 additions and 91 deletions.
2 changes: 2 additions & 0 deletions default.env
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ um/xyw/nKFJcqqMJ71Xq3SY+nA7I0ui4R4W6usx9He6kb5EKlzc9EdVq0w==
-----END PUBLIC KEY-----
"

JWT_SECRET="hs_maa_gaa"

# Postfix
MAIL_DOMAIN=sanctus.omega.ntnu.no
MAIL_RELAY_HOST=mailgw.ntnu.no
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ services:
DOMAIN: ${DOMAIN}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ services:
DOMAIN: ${DOMAIN}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
Expand Down
2 changes: 1 addition & 1 deletion docs
Submodule docs updated from 97b8ef to 3cc94e
5 changes: 3 additions & 2 deletions src/actions/admission/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { createAdmissionTrial } from '@/services/admission/create'
import { createAdmissionTrialValidation } from '@/services/admission/validation'
import { Session } from '@/auth/Session'
import type { ActionReturn } from '@/actions/Types'
import type { Admission, AdmissionTrial } from '@prisma/client'
import type { Admission } from '@prisma/client'
import type { ExpandedAdmissionTrail } from '@/services/admission/Types'


export async function createAdmissionTrialAction(
admission: Admission,
userId: FormData | number
): Promise<ActionReturn<AdmissionTrial>> {
): Promise<ActionReturn<ExpandedAdmissionTrail>> {
const session = await Session.fromNextAuth()
const authRes = CreateAdmissionTrialAuther.dynamicFields({}).auth(session)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,4 @@

.OmegaIdElement {
max-width: 400px;
> p {
width: 100%;
text-align: center;
}
}
}
19 changes: 5 additions & 14 deletions src/app/_components/OmegaId/identification/OmegaIdElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,20 @@
import styles from './OmegaIdElement.module.scss'
import { generateOmegaIdAction } from '@/actions/omegaid/generate'
import { readJWTPayload } from '@/jwt/jwtReadUnsecure'
import { compressOmegaId } from '@/services/omegaid/compress'
import { useQRCode } from 'next-qrcode'
import { useEffect, useState } from 'react'

const EXPIRY_THRESHOLD = 60

type PropTypes = {
export default function OmegaIdElement({ token }: {
token: string,
}

export default function OmegaIdElement({ token }: PropTypes) {
}) {
const [tokenState, setTokenState] = useState(token)

const { SVG } = useQRCode()

const JWTPayload = readJWTPayload<{
gn?: string,
sn?: string,
}>(token)

const firstname = JWTPayload.gn ?? ''
const lastname = JWTPayload.sn ?? ''
const JWTPayload = readJWTPayload(tokenState)

const [expiryTime, setExpiryTime] = useState(new Date((JWTPayload.exp - EXPIRY_THRESHOLD) * 1000))

Expand All @@ -48,9 +41,7 @@ export default function OmegaIdElement({ token }: PropTypes) {

return <div className={styles.OmegaIdElement}>
<SVG
text={tokenState}
text={compressOmegaId(tokenState)}
/>

<p>{firstname} {lastname}</p>
</div>
}
22 changes: 14 additions & 8 deletions src/app/_components/OmegaId/reader/OmegaIdReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import { QRCodeReaderConfig } from './ConfigVars'
import styles from './OmegaIdReader.module.scss'
import { parseJWT } from '@/jwt/parseJWTClient'
import { decompressOmegaId } from '@/services/omegaid/compress'
import { Html5QrcodeScanner } from 'html5-qrcode'
import { useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import type { OmegaId } from '@/services/omegaid/Types'

/**
* Renders a component for reading OmegaId QR codes.
Expand All @@ -26,7 +26,7 @@ export default function OmegaIdReader({
debounceThreshold,
singleRead,
}: {
successCallback: (user: OmegaId, token: string) => Promise<{
successCallback: (user: number, token: string) => Promise<{
success: boolean,
text: string,
}>,
Expand All @@ -51,11 +51,17 @@ export default function OmegaIdReader({
let lastReadTime = 0
let lastReadUserId = -1

html5QrcodeScanner.render(async (token) => {
const parse = await parseJWT(token, publicKey, expiryOffset ?? 100)
html5QrcodeScanner.render(async (rawToken) => {
const token = decompressOmegaId(rawToken)
if (!token.success) {
setFeedBack({
status: 'ERROR',
text: 'Ugyldig QR kode'
})
return
}
const parse = await parseJWT(token.data, publicKey, expiryOffset ?? 100, 'omegaid')
if (!parse.success) {
console.log(parse)

const msg = parse.error?.map(e => e.message).join(' / ') ?? 'Ukjent feil'

setFeedBack({
Expand All @@ -65,7 +71,7 @@ export default function OmegaIdReader({
return
}

const userId = parse.data.id
const userId = parse.data

if (userId === lastReadUserId && Date.now() - lastReadTime < (debounceThreshold ?? 5000)) {
lastReadTime = Date.now()
Expand All @@ -77,7 +83,7 @@ export default function OmegaIdReader({
text: '...',
})

const results = await successCallback(parse.data, token)
const results = await successCallback(userId, token.data)

if (results.success && (singleRead ?? false)) {
html5QrcodeScanner.clear()
Expand Down
4 changes: 4 additions & 0 deletions src/app/admin/SlideSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ const navigations = [
{
title: 'Klasser',
href: '/admin/classes'
},
{
title: 'Studieprogrammer',
href: '/admin/study-programmes'
}
],
},
Expand Down
14 changes: 6 additions & 8 deletions src/app/admin/admission/[admission]/registration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,17 @@ export default function RegisterAdmissiontrial({
<h4>Registrer med QR kode</h4>
<OmegaIdReader
publicKey={omegaIdPublicKey}
successCallback={async (user) => {
const results = await createAdmissionTrialAction(admission, user.id)
successCallback={async (userId) => {
const results = await createAdmissionTrialAction(admission, userId)

let msg = results.success ?
`${user.firstname} er registrert` :
`${results.data.user.firstname} ${results.data.user.lastname} er registrert` :
'Kunne ikke regisrere bruker grunnet en ukjent feil.'

if (!results.success && results.error) {
msg = `${user.firstname}: ${
results.error
.map(e => e.message)
.reduce((acc, val) => `${acc}\n${val}`, '')
}`
msg = results.error
.map(e => e.message)
.reduce((acc, val) => `${acc}\n${val}`, '')
}

return {
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/admission/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default async function AdmissionTrials() {
<ul>
{AdmissionsArray.map(trial =>
<li key={uuid()}>
<Link href={`admissiontrials/${trial}`}>{AdmissionDisplayNames[trial]}</Link>
<Link href={`admission/${trial}`}>{AdmissionDisplayNames[trial]}</Link>
</li>
)}
</ul>
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/omegaid/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export default function OmegaIdContainer({
}) {
return <OmegaIdReader
publicKey={publicKey}
successCallback={async (user) => ({
successCallback={async (userId) => ({
success: true,
text: `${user.firstname} ${user.lastname}`,
text: `userID: ${userId}`,
})}
/>
}
File renamed without changes.
4 changes: 2 additions & 2 deletions src/app/users/[username]/page.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
}

.omegaId {
min-width: 50vw;
min-height: 50vw;
min-width: min(50vw, 400px);
min-height: min(50vw, 400px);
display: grid;
place-items: center;
> * {
Expand Down
6 changes: 4 additions & 2 deletions src/app/users/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export default async function User({ params }: PropTypes) {
{ username: profile.user.username }
).auth(session)

const showOmegaId = session.user?.username === params.username

return (
<div className={styles.wrapper}>
<div className={styles.profile}>
Expand All @@ -57,7 +59,7 @@ export default async function User({ params }: PropTypes) {
<div className={styles.header}>
<div className={styles.nameAndId}>
<h1>{`${profile.user.firstname} ${profile.user.lastname}`}</h1>
<PopUp
{showOmegaId && <PopUp
showButtonClass={styles.omegaIdOpen}
showButtonContent={
<FontAwesomeIcon icon={faQrcode} />
Expand All @@ -67,7 +69,7 @@ export default async function User({ params }: PropTypes) {
<div className={styles.omegaId}>
<OmegaId />
</div>
</PopUp>
</PopUp> }
</div>
{
studyProgramme && (
Expand Down
12 changes: 7 additions & 5 deletions src/lib/jwt/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type JWT<T = Record<string, unknown>> = T & JwtPayloadType['Detailed']
* @param aud - An audience for the token, this is the purpose of the token
* @param payload - The payload to be included in the JWT.
* @param expiresIn - The expiration time of the JWT in seconds.
* @param asymetric - If this is set to true the JWT token will be signed with a private key,
* and can be verified with a public key. The public key is available for all users
* @returns The generated JWT.
*/
export function generateJWT<T extends object>(
Expand All @@ -25,11 +27,11 @@ export function generateJWT<T extends object>(
expiresIn: number,
asymetric = false
): string {
if (!process.env.NEXTAUTH_SECRET || !process.env.JWT_PRIVATE_KEY) {
if (!process.env.JWT_SECRET || !process.env.JWT_PRIVATE_KEY) {
throw new ServerError('INVALID CONFIGURATION', 'Missing secret for JWT generation')
}

return sign(payload, asymetric ? process.env.JWT_PRIVATE_KEY : process.env.NEXTAUTH_SECRET, {
return sign(payload, asymetric ? process.env.JWT_PRIVATE_KEY : process.env.JWT_SECRET, {
audience: aud,
algorithm: asymetric ? 'ES256' : 'HS256',
issuer: JWT_ISSUER,
Expand All @@ -44,16 +46,16 @@ export function generateJWT<T extends object>(
* @throws {ServerError} If the JWT is expired or invalid.
*/
export function verifyJWT(token: string, aud?: OmegaJWTAudience): (jwt.JwtPayload & Record<string, string | number | null>) {
if (!process.env.NEXTAUTH_SECRET || !process.env.JWT_PUBLIC_KEY) {
if (!process.env.JWT_SECRET || !process.env.JWT_PUBLIC_KEY) {
throw new ServerError(
'INVALID CONFIGURATION',
'JWT environ variables is not set. Missing NEXTAUTH_SECRET or JWT_PUBLIC_KEY'
'JWT environ variables is not set. Missing JWT_SECRET or JWT_PUBLIC_KEY'
)
}

try {
const JWTHeader = readJWTPart(token, 0)
let jwtKey = process.env.NEXTAUTH_SECRET
let jwtKey = process.env.JWT_SECRET
if (JWTHeader.alg === 'ES256') {
jwtKey = process.env.JWT_PUBLIC_KEY
}
Expand Down
34 changes: 14 additions & 20 deletions src/lib/jwt/parseJWTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { JWT_ISSUER } from '@/auth/ConfigVars'
import { createActionError } from '@/actions/error'
import type { OmegaJWTAudience } from '@/auth/Types'
import type { ActionReturn } from '@/actions/Types'
import type { OmegaId } from '@/services/omegaid/Types'

/**
* Parses a JSON Web Token (JWT) and verifies its signature using the provided public key.
Expand All @@ -16,11 +15,16 @@ import type { OmegaId } from '@/services/omegaid/Types'
* @returns A promise that resolves to an `ActionReturn` object containing the parsed JWT payload if the JWT is valid,
* or an error object if the JWT is invalid.
*/
export async function parseJWT(token: string, publicKey: string, timeOffset: number): Promise<ActionReturn<OmegaId>> {
export async function parseJWT(
token: string,
publicKey: string,
timeOffset: number,
audience: OmegaJWTAudience
): Promise<ActionReturn<number>> {
// TODO: This only works in safari and firefox :///

function invalidJWT(message?: string): ActionReturn<OmegaId> {
return createActionError('JWT INVALID', message || 'Ugyldig QR kode')
function invalidJWT(message?: string): ActionReturn<number> {
return createActionError('JWT INVALID', message || 'Invalid JWT')
}

if (timeOffset < 0) {
Expand All @@ -30,7 +34,7 @@ export async function parseJWT(token: string, publicKey: string, timeOffset: num

const tokenS = token.split('.')
if (tokenS.length !== 3) {
return invalidJWT('Ugyldig QR kode type')
return invalidJWT('Malformatted JWT')
}

const keyStripped = publicKey
Expand Down Expand Up @@ -68,35 +72,25 @@ export async function parseJWT(token: string, publicKey: string, timeOffset: num
try {
const payload = readJWTPayload(token)

if (!(
typeof payload.usrnm === 'string' &&
typeof payload.gn === 'string' &&
typeof payload.sn === 'string' &&
typeof payload.sub === 'number'
)) {
return invalidJWT('Invalid fields')
if (typeof payload.sub !== 'number') {
return invalidJWT('JWT is missing sub field')
}

if (new Date(payload.exp * 1000 + timeOffset) < new Date()) {
return invalidJWT('QR koden er utløpt')
return invalidJWT('JWT has expired')
}

if (payload.iss !== JWT_ISSUER) {
return invalidJWT('Invalid issuer')
}

if (payload.aud !== 'omegaid' satisfies OmegaJWTAudience) {
if (payload.aud !== audience) {
return invalidJWT('Invalid audience')
}

return {
success: true,
data: {
id: payload.sub,
username: payload.usrnm,
firstname: payload.gn,
lastname: payload.sn,
}
data: payload.sub
}
} catch {
return invalidJWT('An unexpected error occured')
Expand Down
7 changes: 7 additions & 0 deletions src/services/admission/Types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { AdmissionTrial } from '@prisma/client'
import type { UserFiltered } from '@/services/users/Types'


export type ExpandedAdmissionTrail = AdmissionTrial & {
user: UserFiltered
}
Loading

0 comments on commit 4cd1517

Please sign in to comment.