Skip to content

Commit

Permalink
Merge branch 'main' into DDFBRA-90-kort-i-sogeresultat-grid
Browse files Browse the repository at this point in the history
  • Loading branch information
Adamik10 committed Oct 22, 2024
2 parents e17574b + b2145f5 commit 910199c
Show file tree
Hide file tree
Showing 19 changed files with 1,619 additions and 21 deletions.
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
NEXT_PUBLIC_GRAPHQL_SCHEMA_ENDPOINT_DPL_CMS=http://dapple-cms.docker/graphql
NEXT_PUBLIC_GRAPHQL_SCHEMA_ENDPOINT_FBI=https://fbi-api.dbc.dk/ereolgo/graphql
NEXT_PUBLIC_LIBRARY_TOKEN=XXX
// WILL BE REPLACED WITH DYNAMIC TOKEN
NEXT_PUBLIC_LIBRARY_TOKEN=XXX
SESSION_SECRET=XXX

NEXT_PUBLIC_UNILOGIN_API_URL=https://et-broker.unilogin.dk
NEXT_PUBLIC_UNILOGIN_WELKNOWN_URL=https://et-broker.unilogin.dk/auth/realms/broker/.well-known/openid-configuration
NEXT_PUBLIC_UNILOGIN_SCOPE="openid"
NEXT_PUBLIC_UNILOGIN_CLIENT_ID=XXX
NEXT_PUBLIC_UNILOGIN_CLIENT_SECRET=XXXX
NEXT_PUBLIC_APP_URL=http://localhost:3000
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20
v20.10
118 changes: 118 additions & 0 deletions __tests__/refresh-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment node
import { add } from "date-fns";
import { getIronSession, IronSession, } from "iron-session";
import { testApiHandler } from 'next-test-api-route-handler';
import { afterAll, beforeAll, beforeEach, expect, test, vi } from "vitest";

import * as tokenRefreshHandler from '@/app/auth/token/refresh/route';
import { accessTokenShouldBeRefreshed, TSessionData } from "@/lib/session/session";

vi.mock('iron-session', () => ({
getIronSession: vi.fn(),
}));

beforeAll(() => {
const fixedDate = new Date('2024-01-01T00:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(fixedDate);
});

afterAll(() => {
// Restore real timers
vi.useRealTimers();
});

beforeEach(() => {
getIronSession.mockResolvedValue({
isLoggedIn: true,
});
})

const sessionThatShouldBeRefreshed = () => ({
isLoggedIn: true,
type: "unilogin",
expires: add(new Date(), {seconds: 59}),
refresh_expires: add(new Date(), {seconds: 59}),
access_token: "access_token",
refresh_token: "refresh",
id_token: "id",
});

test('That the refresh endpoint redirects to the frontpage if there is no active session', async () => {
// Simulate an anonymous session.
getIronSession.mockResolvedValue({
isLoggedIn: false,
});


await testApiHandler({
appHandler: tokenRefreshHandler,
url: `/?redirect=http://john.johnson.com/john`,
async test({ fetch }) {
const res = await fetch({ method: 'GET' });
expect(res.headers.get('location')).toEqual("http://localhost:3000/");
}
});
});

test('That the refresh endpoint redirects to the given endpoint after refreshing token', async () => {
// This is an authorized session that should be refreshed.
getIronSession.mockResolvedValue(sessionThatShouldBeRefreshed());

await testApiHandler({
appHandler: tokenRefreshHandler,
url: `/?redirect=http://john.johnson.com/john`,
async test({ fetch }) {
const res = await fetch({ method: 'GET' });
expect(res.headers.get('location')).toEqual("http://john.johnson.com/john");
}
});

// This is an authorized session that should NOT be refreshed.
getIronSession.mockResolvedValue({
isLoggedIn: true,
expires: add(new Date(), {seconds: 300}),
refresh_expires: add(new Date(), {seconds: 1800}),
access_token: "access_token",
refresh_token: "refresh"
});
});

// TODO: Write tests that proves that the session object is updated correctly after a successful refresh.

test('That the refreshValidation validates if the access token should be refreshed correctly', async () => {
// Since there is a buffer of 1 minute added to the refresh time,
// the access token should be refreshed 1 minute before it expires.
expect(accessTokenShouldBeRefreshed({
type: "unilogin",
expires: add(new Date(), {seconds: 59}),
refresh_expires: add(new Date(), {seconds: 59}),
isLoggedIn: true,
} as IronSession<TSessionData>)).toBe(true);

// Since there is a buffer of 1 minute added to the refresh time,
// the access token should be refreshed 1 minute before it expires.
// The tipping point in this case is the 60th second.
expect(accessTokenShouldBeRefreshed({
type: "unilogin",
expires: add(new Date(), {seconds: 60}),
refresh_expires: add(new Date(), {seconds: 60}),
isLoggedIn: true,
} as IronSession<TSessionData>)).toBe(false);

// The refresh logic looks at both expires and refresh_expires.
// Here the expires is the tipping point.
expect(accessTokenShouldBeRefreshed({
type: "unilogin",
expires: add(new Date(), {seconds: 59}),
refresh_expires: add(new Date(), {seconds: 1800}),
isLoggedIn: true,
} as IronSession<TSessionData>)).toBe(true);
// Here the refresh_expires is the tipping point.
expect(accessTokenShouldBeRefreshed({
type: "unilogin",
expires: add(new Date(), {seconds: 300}),
refresh_expires: add(new Date(), {seconds: 59}),
isLoggedIn: true,
} as IronSession<TSessionData>)).toBe(true);
});
63 changes: 63 additions & 0 deletions app/auth/callback/unilogin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { IncomingMessage } from "http";
import { IntrospectionResponse } from "openid-client";

import {
getUniloginClient,
uniloginClientConfig} from "@/lib/session/oauth/uniloginClient";
import { getSession, setTokensOnSession } from "@/lib/session/session";
import { TTokenSet } from "@/lib/types/session";

import schemas from "./schemas";

export interface TIntrospectionResponse extends IntrospectionResponse {
uniid: string;
institutionIds: string;
}

export async function GET(request: IncomingMessage) {
const session = await getSession();
const client = await getUniloginClient();
const params = client.callbackParams(request);

// Fetch all user/token info.
try {
const tokenSetResponse = await client.callback(
uniloginClientConfig.redirect_uri,
params,
{
code_verifier: session.code_verifier
}
);
const tokenSet = schemas.tokenSet.parse(tokenSetResponse) as TTokenSet;

const introspectResponse = (await client.introspect(
tokenSet.access_token!
)) as TIntrospectionResponse;
const introspect = schemas.introspect.parse(introspectResponse);

const userinfoResponse = await client.userinfo(tokenSetResponse);
const userinfo = schemas.userInfo.parse(userinfoResponse);

// Set basic session info.
session.isLoggedIn = true;
session.type = "unilogin";

// Set token info.
setTokensOnSession(session, tokenSet);

// Set user info.
session.userInfo = {
sub: userinfo.sub,
uniid: introspect.uniid,
institutionIds: introspect.institutionIds
};

await session.save();

return Response.redirect(uniloginClientConfig.post_login_route);
} catch (error) {
console.error(error);
// TODO: Error page or redirect to login page.
return Response.redirect("/");
}
}
20 changes: 20 additions & 0 deletions app/auth/callback/unilogin/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from "zod";

const schemas = {
tokenSet: z.object({
access_token: z.string(),
refresh_token: z.string(),
id_token: z.string(),
expires_in: z.number(),
refresh_expires_in: z.number()
}),
introspect: z.object({
uniid: z.string(),
institutionIds: z.string()
}),
userInfo: z.object({
sub: z.string()
})
};

export default schemas;
27 changes: 27 additions & 0 deletions app/auth/login/unilogin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { generators } from "openid-client";

import {
getUniloginClient,
uniloginClientConfig
} from "@/lib/session/oauth/uniloginClient";
import { getSession } from "@/lib/session/session";

export async function GET() {
const session = await getSession();

session.code_verifier = generators.codeVerifier();

const code_challenge = generators.codeChallenge(session.code_verifier);

const client = await getUniloginClient();
const url = client.authorizationUrl({
scope: uniloginClientConfig.scope,
audience: uniloginClientConfig.audience,
redirect_uri: uniloginClientConfig.redirect_uri,
code_challenge,
code_challenge_method: "S256"
});

await session.save();
return Response.redirect(url);
}
35 changes: 35 additions & 0 deletions app/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { generators } from "openid-client";

import {
getUniloginClient,
uniloginClientConfig
} from "@/lib/session/oauth/uniloginClient";
import { defaultSession, getSession } from "@/lib/session/session";

export async function GET() {
const session = await getSession();
const frontpage = `${process.env.NEXT_PUBLIC_APP_URL!}/`;

switch (session.type) {
case "unilogin":
const id_token = cookies().get("go-session:id_token")?.value;
// TODO: Is this where we want to redirect to if id token cannot be resolved?
if (!id_token) {
return Response.redirect("/");
}
const client = await getUniloginClient();
const endSession = client.endSessionUrl({
post_logout_redirect_uri: uniloginClientConfig.post_logout_redirect_uri,
id_token_hint: id_token,
state: generators.state()
});
session.isLoggedIn = defaultSession.isLoggedIn;
session.access_token = defaultSession.access_token;
session.userInfo = defaultSession.userInfo;
await session.save();
return Response.redirect(endSession);
default:
return Response.redirect(frontpage);
}
}
16 changes: 16 additions & 0 deletions app/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defaultSession, getSession } from "@/lib/session/session";

export async function GET() {
try {
const session = await getSession();
if (!session) {
return Response.json({ defaultSession });
}
return Response.json({
isLoggedIn: session.isLoggedIn,
userInfo: session.userInfo
});
} catch (e) {
return Response.json({ error: e }, { status: 500 });
}
}
52 changes: 52 additions & 0 deletions app/auth/token/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

import { getUniloginClient } from "@/lib/session/oauth/uniloginClient";
import { getSession, setTokensOnSession } from "@/lib/session/session";
import { TTokenSet } from "@/lib/types/session";

const sessionTokenSchema = z.object({
isLoggedIn: z.boolean(),
access_token: z.string(),
refresh_token: z.string()
});

export async function GET(request: NextRequest, response: NextResponse) {
const session = await getSession();
const frontpage = `${process.env.NEXT_PUBLIC_APP_URL!}/`;

// If the user is not logged in, we redirect to the frontpage.
if (!session.isLoggedIn) {
return NextResponse.redirect(frontpage, { headers: response.headers });
}
const redirect = request.nextUrl.searchParams.get("redirect");
console.log("Redirecting to:", redirect);
// We need the redirect URL to be present in the query string.
if (!redirect) {
return NextResponse.redirect(frontpage, { headers: response.headers });
}

try {
// TODO: Consider if we want to handle different types of sessions than unilogin.
const tokens = sessionTokenSchema.parse(session);
const client = await getUniloginClient();
const newTokens = await (client.refresh(
tokens.refresh_token
) as Promise<TTokenSet>);
setTokensOnSession(session, newTokens);
await session.save();
console.log("Tokens refreshed successfully:", newTokens);
} catch (error) {
// TODO: maybe distinguish between ZodError and other errors?
// TODO: Should we redirect to an end-of-session page?
// Session is corrupt so we need to destroy it.
session.destroy();

const isZodError = error instanceof z.ZodError;
console.error(isZodError ? JSON.stringify(error.errors) : error);
} finally {
return NextResponse.redirect(redirect, { headers: response.headers });
}
}

export const dynamic = "force-dynamic";
10 changes: 8 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import Image from "next/image"
import Image from "next/image";

export default function Home() {
import ExampleUniloginButton from "@/components/shared/login/ExampleUniloginButton";

export default async function Home() {
return (
<div
className="grid min-h-screen grid-rows-[20px_1fr_20px] items-center justify-items-center gap-16 p-8 pb-20
font-[family-name:var(--font-geist-sans)] sm:p-20">
<main className="row-start-2 flex flex-col items-center gap-8 sm:items-start">
<div>
<h2>Example login:</h2>
<ExampleUniloginButton />
</div>
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
Expand Down
Loading

0 comments on commit 910199c

Please sign in to comment.