Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/omega id #359

Merged
merged 9 commits into from
Nov 7, 2024
Merged
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
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}`,
})}
/>
}
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
Loading