-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into DDFBRA-90-kort-i-sogeresultat-grid
- Loading branch information
Showing
19 changed files
with
1,619 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
v20 | ||
v20.10 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("/"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.