@snokam/auth 2.2.22-alpha.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +374 -1041
- package/package.json +36 -48
- package/src/app-router/components/AuthComplete.styled.tsx +17 -0
- package/src/app-router/components/AuthComplete.tsx +52 -0
- package/src/app-router/components/Login.tsx +120 -0
- package/src/app-router/components/Provider/index.tsx +55 -0
- package/src/app-router/index.tsx +56 -0
- package/src/app-router/server.ts +93 -0
- package/src/build-proxy.tsx +160 -0
- package/src/components/Authenticate.tsx +38 -0
- package/src/components/Authenticated.tsx +11 -0
- package/src/components/Consent/index.tsx +35 -0
- package/src/components/EmployeeGate/index.tsx +59 -0
- package/src/components/Error/index.tsx +90 -0
- package/src/components/Error/styled.tsx +63 -0
- package/src/components/InactiveEmployee/index.tsx +18 -0
- package/src/components/LoggedOut/index.tsx +23 -0
- package/src/components/NotProvisioned/index.tsx +19 -0
- package/src/components/Unauthenticated.tsx +11 -0
- package/src/config.ts +9 -0
- package/src/handoff/handlers.ts +146 -0
- package/src/handoff/return-to.ts +40 -0
- package/src/handoff/ticket.ts +97 -0
- package/src/hooks.ts +146 -0
- package/src/impersonation/context.tsx +185 -0
- package/src/impersonation/cookies.ts +124 -0
- package/src/impersonation/handlers.ts +234 -0
- package/src/impersonation/routes.ts +30 -0
- package/src/impersonation/ticket.ts +79 -0
- package/src/index.tsx +31 -0
- package/src/options.tsx +389 -0
- package/src/page-router/components/Login.tsx +50 -0
- package/src/page-router/components/Provider/index.tsx +45 -0
- package/src/page-router/index.tsx +22 -0
- package/src/page-router/server.ts +6 -0
- package/src/password/api.ts +183 -0
- package/src/roles/context.tsx +54 -0
- package/src/route-handler/token.ts +124 -0
- package/src/route-handler.ts +52 -0
- package/src/scopes/context.tsx +127 -0
- package/src/scopes/matcher.ts +47 -0
- package/src/server/auth.tsx +30 -0
- package/src/server/handler.tsx +68 -0
- package/src/server/token.tsx +10 -0
- package/{lib/server.d.ts → src/server.tsx} +7 -4
- package/src/sso/exchange.ts +64 -0
- package/src/sso/tenant-cookie.ts +18 -0
- package/src/theme/employee-theme-sync.tsx +55 -0
- package/src/token/claims.ts +12 -0
- package/src/utils/callback-url.ts +17 -0
- package/src/utils/consent.ts +22 -0
- package/src/utils/cookies.ts +75 -0
- package/src/utils/cors.ts +68 -0
- package/src/utils/params.ts +15 -0
- package/src/utils/types.ts +52 -0
- package/src/utils/url.ts +19 -0
- package/lib/components/Authenticate.d.ts +0 -2
- package/lib/components/Authenticated.d.ts +0 -6
- package/lib/components/Error/index.d.ts +0 -5
- package/lib/components/Login.d.ts +0 -2
- package/lib/components/Provider/index.d.ts +0 -7
- package/lib/components/Unauthenticated.d.ts +0 -6
- package/lib/hooks.d.ts +0 -10
- package/lib/index.d.ts +0 -14
- package/lib/index.js +0 -42
- package/lib/index.js.map +0 -1
- package/lib/middleware.d.ts +0 -7
- package/lib/middleware.js +0 -2
- package/lib/middleware.js.map +0 -1
- package/lib/options.d.ts +0 -5
- package/lib/options.js +0 -2
- package/lib/options.js.map +0 -1
- package/lib/server/auth.d.ts +0 -11
- package/lib/server/handler.d.ts +0 -14
- package/lib/server/token.d.ts +0 -8
- package/lib/server.js +0 -2
- package/lib/server.js.map +0 -1
- package/lib/utils/cookies.d.ts +0 -5
- package/lib/utils/params.d.ts +0 -1
- package/lib/utils/types.d.ts +0 -39
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { memo, useEffect } from "react";
|
|
2
|
+
import { Auth } from "../utils/types";
|
|
3
|
+
import { signIn } from "next-auth/react";
|
|
4
|
+
import { useAuth } from "../hooks";
|
|
5
|
+
import { PageLoader } from "@snokam/ui";
|
|
6
|
+
import { useOptionalConfig } from "@snokam/config";
|
|
7
|
+
|
|
8
|
+
export const Authenticate = memo((props: Auth.Authenticate.Props) => {
|
|
9
|
+
const { children, provider } = props;
|
|
10
|
+
const { inProgress, isAuthenticated } = useAuth();
|
|
11
|
+
const config = useOptionalConfig();
|
|
12
|
+
const central = config?.NEXT_PUBLIC_CENTRAL_AUTH_URL;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (inProgress || isAuthenticated) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const onLocalhost = window.location.hostname === "localhost";
|
|
20
|
+
const onCentral =
|
|
21
|
+
onLocalhost || (central && central.startsWith(window.location.origin));
|
|
22
|
+
if (central && !onCentral) {
|
|
23
|
+
const returnTo = window.location.href;
|
|
24
|
+
window.location.href = `${central}/handoff/start?returnTo=${encodeURIComponent(
|
|
25
|
+
returnTo
|
|
26
|
+
)}`;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
signIn(provider);
|
|
31
|
+
}, [inProgress, isAuthenticated, provider, central]);
|
|
32
|
+
|
|
33
|
+
if (!isAuthenticated) {
|
|
34
|
+
return <PageLoader />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return children;
|
|
38
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useSession } from "next-auth/react";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Authenticated = ({ children }: Props) => {
|
|
9
|
+
const { status } = useSession();
|
|
10
|
+
return status === "authenticated" ? children : null;
|
|
11
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Button from "@mui/material/Button";
|
|
4
|
+
import { useOptionalConfig } from "@snokam/config";
|
|
5
|
+
import { Error } from "../Error";
|
|
6
|
+
|
|
7
|
+
export const Consent = () => {
|
|
8
|
+
const config = useOptionalConfig();
|
|
9
|
+
const manageUrl = `${config?.NEXT_PUBLIC_PLATFORM_BASE_URL ?? ""}/manage`;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Error
|
|
13
|
+
heading="Tilgang må godkjennes"
|
|
14
|
+
message={
|
|
15
|
+
<>
|
|
16
|
+
Organisasjonen din har ikke tatt i bruk Snøkam ennå.
|
|
17
|
+
<br />
|
|
18
|
+
Administrer tilgangen for bedriften din.
|
|
19
|
+
</>
|
|
20
|
+
}
|
|
21
|
+
action={
|
|
22
|
+
<Button
|
|
23
|
+
sx={{ mt: 3 }}
|
|
24
|
+
color="primary"
|
|
25
|
+
variant="contained"
|
|
26
|
+
href={manageUrl}
|
|
27
|
+
>
|
|
28
|
+
Gå til administrasjon
|
|
29
|
+
</Button>
|
|
30
|
+
}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default Consent;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect } from "react";
|
|
4
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
5
|
+
import { PageLoader } from "@snokam/ui";
|
|
6
|
+
import { useAuth } from "../../hooks";
|
|
7
|
+
import { NotProvisioned } from "../NotProvisioned";
|
|
8
|
+
import { InactiveEmployee } from "../InactiveEmployee";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
employee: unknown;
|
|
12
|
+
isLoading: boolean;
|
|
13
|
+
allowAnonymous?: boolean;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const EmployeeGate = ({
|
|
18
|
+
employee,
|
|
19
|
+
isLoading,
|
|
20
|
+
allowAnonymous = false,
|
|
21
|
+
children,
|
|
22
|
+
}: Props) => {
|
|
23
|
+
const { isAuthenticated, inProgress } = useAuth();
|
|
24
|
+
const router = useRouter();
|
|
25
|
+
const pathname = usePathname();
|
|
26
|
+
const anonymous = !inProgress && !isAuthenticated;
|
|
27
|
+
const isAuthRoute = pathname?.startsWith("/auth") ?? false;
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!allowAnonymous && anonymous && !isAuthRoute) {
|
|
31
|
+
const fullPath = `${window.location.pathname}${window.location.search}`;
|
|
32
|
+
router.push(`/auth/login?callbackUrl=${encodeURIComponent(fullPath)}`);
|
|
33
|
+
}
|
|
34
|
+
}, [allowAnonymous, anonymous, isAuthRoute, pathname, router]);
|
|
35
|
+
|
|
36
|
+
if (isAuthRoute) {
|
|
37
|
+
return <>{children}</>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (inProgress || (isAuthenticated && isLoading)) {
|
|
41
|
+
return <PageLoader />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isAuthenticated && !employee) {
|
|
45
|
+
return <NotProvisioned />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isAuthenticated && (employee as { active?: boolean }).active === false) {
|
|
49
|
+
return <InactiveEmployee />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (anonymous && !allowAnonymous) {
|
|
53
|
+
return <PageLoader />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <>{children}</>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default EmployeeGate;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
import Styled from "./styled";
|
|
5
|
+
import { Layout } from "@snokam/ui";
|
|
6
|
+
import { TransitionType } from "@snokam/ui/layout";
|
|
7
|
+
import { useAuth } from "../../hooks";
|
|
8
|
+
import { useOptionalConfig } from "@snokam/config";
|
|
9
|
+
import Button from "@mui/material/Button";
|
|
10
|
+
import { Avatar, Alert } from "@mui/material";
|
|
11
|
+
|
|
12
|
+
export interface ErrorPageProps {
|
|
13
|
+
message?: ReactNode;
|
|
14
|
+
heading?: string;
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
hideRetryButton?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Error = ({
|
|
20
|
+
message,
|
|
21
|
+
heading,
|
|
22
|
+
action,
|
|
23
|
+
hideRetryButton,
|
|
24
|
+
}: ErrorPageProps) => {
|
|
25
|
+
const { session, signOut } = useAuth();
|
|
26
|
+
|
|
27
|
+
const config = useOptionalConfig();
|
|
28
|
+
const isProd = config?.NEXT_PUBLIC_ENVIRONMENT === "p";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Styled.Container
|
|
32
|
+
transitions={{
|
|
33
|
+
bottom: {
|
|
34
|
+
type: TransitionType.Wave,
|
|
35
|
+
},
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<Layout.Content>
|
|
39
|
+
<Styled.Section>
|
|
40
|
+
<Styled.Wrapper>
|
|
41
|
+
<Styled.Heading>{heading ?? "Oops! Noe gikk galt."}</Styled.Heading>
|
|
42
|
+
<Styled.Illustration
|
|
43
|
+
src="https://ressurser.snokam.no/figures/snowman.svg"
|
|
44
|
+
alt="Feil illustrasjon"
|
|
45
|
+
/>
|
|
46
|
+
<Styled.Description>
|
|
47
|
+
{message ?? (
|
|
48
|
+
<>
|
|
49
|
+
En uventet feil har oppstått.
|
|
50
|
+
<br />
|
|
51
|
+
Prøv igjen, eller logg inn på nytt.
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</Styled.Description>
|
|
55
|
+
{session && session.user && (
|
|
56
|
+
<Styled.User>
|
|
57
|
+
<Avatar src={session.user.image ?? undefined} />
|
|
58
|
+
Logget inn som: {session.user.name}
|
|
59
|
+
<br />({session.user.email})
|
|
60
|
+
</Styled.User>
|
|
61
|
+
)}
|
|
62
|
+
{!isProd && (
|
|
63
|
+
<Alert severity="warning">
|
|
64
|
+
Du er i test! Husk at det kun er mulig å logge inn <br />
|
|
65
|
+
med en av våre testbrukere som du finner i 1Password.
|
|
66
|
+
</Alert>
|
|
67
|
+
)}
|
|
68
|
+
{action}
|
|
69
|
+
{!hideRetryButton && (
|
|
70
|
+
<Button
|
|
71
|
+
sx={{ mt: 3 }}
|
|
72
|
+
color="secondary"
|
|
73
|
+
variant={action ? "outlined" : "contained"}
|
|
74
|
+
onClick={() => {
|
|
75
|
+
signOut({
|
|
76
|
+
callbackUrl: "/auth/login?callbackUrl=/&prompt=login",
|
|
77
|
+
});
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
Logg inn på nytt
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</Styled.Wrapper>
|
|
84
|
+
</Styled.Section>
|
|
85
|
+
</Layout.Content>
|
|
86
|
+
</Styled.Container>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default Error;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import styled from "@emotion/styled";
|
|
2
|
+
import Layout from "@snokam/ui/layout";
|
|
3
|
+
|
|
4
|
+
const Container = styled(Layout.Container)`
|
|
5
|
+
flex: 1;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
`;
|
|
8
|
+
|
|
9
|
+
const Wrapper = styled.div`
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: 1.5rem;
|
|
13
|
+
align-items: center;
|
|
14
|
+
width: 100%;
|
|
15
|
+
max-width: 600px;
|
|
16
|
+
padding: 2rem;
|
|
17
|
+
background-color: var(--color-dark-green);
|
|
18
|
+
border-radius: 1rem;
|
|
19
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.9);
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const Heading = styled.h1`
|
|
23
|
+
font-size: 2rem;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
margin: 0;
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const Illustration = styled.img`
|
|
29
|
+
width: 200px;
|
|
30
|
+
max-width: 100%;
|
|
31
|
+
height: auto;
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const Section = styled(Layout.Section)`
|
|
35
|
+
display: flex;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
align-items: center;
|
|
38
|
+
color: var(--color-light-blue);
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const Description = styled.div`
|
|
42
|
+
display: flex;
|
|
43
|
+
font-size: 1.15rem;
|
|
44
|
+
text-align: center;
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const User = styled.div`
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
font-size: 1rem;
|
|
51
|
+
margin-top: 1rem;
|
|
52
|
+
gap: 1rem;
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
Container,
|
|
57
|
+
Wrapper,
|
|
58
|
+
Heading,
|
|
59
|
+
Illustration,
|
|
60
|
+
Description,
|
|
61
|
+
Section,
|
|
62
|
+
User,
|
|
63
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Error } from "../Error";
|
|
4
|
+
|
|
5
|
+
export const InactiveEmployee = () => (
|
|
6
|
+
<Error
|
|
7
|
+
heading="Venter på tilgang"
|
|
8
|
+
message={
|
|
9
|
+
<>
|
|
10
|
+
Brukeren din er registrert, men er ikke aktivert ennå.
|
|
11
|
+
<br />
|
|
12
|
+
Ta kontakt med administratoren din for å få tilgang.
|
|
13
|
+
</>
|
|
14
|
+
}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export default InactiveEmployee;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Button from "@mui/material/Button";
|
|
4
|
+
import Error from "../Error";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
onLogin: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const LoggedOut = ({ onLogin }: Props) => (
|
|
11
|
+
<Error
|
|
12
|
+
heading="Du er logget ut"
|
|
13
|
+
message="Velkommen tilbake når det passer deg."
|
|
14
|
+
hideRetryButton
|
|
15
|
+
action={
|
|
16
|
+
<Button color="secondary" variant="contained" onClick={onLogin}>
|
|
17
|
+
Logg inn
|
|
18
|
+
</Button>
|
|
19
|
+
}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export default LoggedOut;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Error } from "../Error";
|
|
4
|
+
|
|
5
|
+
export const NotProvisioned = () => (
|
|
6
|
+
<Error
|
|
7
|
+
heading="Mangler tilgang"
|
|
8
|
+
message={
|
|
9
|
+
<>
|
|
10
|
+
Du er logget inn, men brukeren din er ikke registrert som ansatt her
|
|
11
|
+
ennå.
|
|
12
|
+
<br />
|
|
13
|
+
Ta kontakt med administratoren din dersom dette ikke stemmer.
|
|
14
|
+
</>
|
|
15
|
+
}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export default NotProvisioned;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useSession } from "next-auth/react";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const Unauthenticated = ({ children }: Props) => {
|
|
9
|
+
const { status } = useSession();
|
|
10
|
+
return status === "unauthenticated" ? children : null;
|
|
11
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Auth } from "./utils/types";
|
|
2
|
+
|
|
3
|
+
export const getAuthConfig = (): Auth.Config => ({
|
|
4
|
+
clientId: process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID ?? "",
|
|
5
|
+
tenantId: process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID ?? "",
|
|
6
|
+
clientSecret: process.env.AZURE_AD_SECRET ?? "",
|
|
7
|
+
nextAuthSecret: process.env.NEXTAUTH_SECRET ?? "",
|
|
8
|
+
environment: process.env.NEXT_PUBLIC_ENVIRONMENT,
|
|
9
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getToken } from "next-auth/jwt";
|
|
2
|
+
import { getSessionCookieSettings } from "../utils/cookies";
|
|
3
|
+
import { getAuthUrl, requestOrigin } from "../utils/url";
|
|
4
|
+
import { sealHandoff } from "./ticket";
|
|
5
|
+
import { isAllowedReturnTo } from "./return-to";
|
|
6
|
+
import { safeCallbackTarget } from "../utils/callback-url";
|
|
7
|
+
import { revokePasswordToken } from "../password/api";
|
|
8
|
+
|
|
9
|
+
interface HandoffConfig {
|
|
10
|
+
nextAuthSecret: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function validReturnTo(request: Request): Promise<string | null> {
|
|
14
|
+
const returnTo = new URL(request.url).searchParams.get("returnTo");
|
|
15
|
+
if (!returnTo) return null;
|
|
16
|
+
const allowed = await isAllowedReturnTo(returnTo, {
|
|
17
|
+
platformApiBase: process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "",
|
|
18
|
+
platformBaseDomain: process.env.NEXT_PUBLIC_PLATFORM_DOMAIN ?? "",
|
|
19
|
+
allowLocalhost: process.env.AUTH_ALLOW_LOCALHOST === "true",
|
|
20
|
+
});
|
|
21
|
+
return allowed ? returnTo : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function centralToken(request: Request, secret: string) {
|
|
25
|
+
const { name: cookieName } = getSessionCookieSettings(getAuthUrl());
|
|
26
|
+
return getToken({ req: request as never, secret, cookieName });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HandoffToken {
|
|
30
|
+
oid?: unknown;
|
|
31
|
+
tid?: unknown;
|
|
32
|
+
email?: unknown;
|
|
33
|
+
name?: unknown;
|
|
34
|
+
refreshToken?: unknown;
|
|
35
|
+
authMethod?: unknown;
|
|
36
|
+
picture?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function portableImage(picture: unknown): string | undefined {
|
|
40
|
+
return typeof picture === "string" && picture.startsWith("https://")
|
|
41
|
+
? picture
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function completeRedirect(
|
|
46
|
+
token: HandoffToken,
|
|
47
|
+
returnTo: string,
|
|
48
|
+
secret: string
|
|
49
|
+
): Response {
|
|
50
|
+
const business = new URL(returnTo);
|
|
51
|
+
const image = portableImage(token.picture);
|
|
52
|
+
const ticket = sealHandoff(
|
|
53
|
+
{
|
|
54
|
+
oid: String(token.oid ?? ""),
|
|
55
|
+
email: String(token.email ?? ""),
|
|
56
|
+
name: String(token.name ?? ""),
|
|
57
|
+
tid: String(token.tid ?? ""),
|
|
58
|
+
business: business.hostname,
|
|
59
|
+
refreshToken: String(token.refreshToken ?? ""),
|
|
60
|
+
...(token.authMethod ? { authMethod: String(token.authMethod) } : {}),
|
|
61
|
+
...(image ? { image } : {}),
|
|
62
|
+
},
|
|
63
|
+
secret
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const complete = new URL("/auth/complete", business.origin);
|
|
67
|
+
complete.searchParams.set("ticket", ticket);
|
|
68
|
+
complete.searchParams.set(
|
|
69
|
+
"callbackUrl",
|
|
70
|
+
safeCallbackTarget(business.pathname + business.search, business.origin)
|
|
71
|
+
);
|
|
72
|
+
return Response.redirect(complete.toString(), 302);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function expireCookie(name: string, domain?: string, secure?: boolean): string {
|
|
76
|
+
return [
|
|
77
|
+
`${name}=`,
|
|
78
|
+
"Path=/",
|
|
79
|
+
"Max-Age=0",
|
|
80
|
+
"HttpOnly",
|
|
81
|
+
"SameSite=Lax",
|
|
82
|
+
domain ? `Domain=${domain}` : "",
|
|
83
|
+
secure ? "Secure" : "",
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join("; ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createHandoffLogoutHandler(config?: HandoffConfig) {
|
|
90
|
+
return async function GET(request: Request) {
|
|
91
|
+
const returnTo = (await validReturnTo(request)) ?? requestOrigin(request);
|
|
92
|
+
const { name, domain, secure } = getSessionCookieSettings(getAuthUrl());
|
|
93
|
+
|
|
94
|
+
if (config?.nextAuthSecret) {
|
|
95
|
+
const token = await centralToken(request, config.nextAuthSecret);
|
|
96
|
+
if (token?.authMethod === "password" && token.refreshToken) {
|
|
97
|
+
await revokePasswordToken(String(token.refreshToken));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const headers = new Headers({ Location: returnTo });
|
|
102
|
+
for (const cookieName of [name, `${name}.0`, `${name}.1`]) {
|
|
103
|
+
headers.append("Set-Cookie", expireCookie(cookieName, domain, secure));
|
|
104
|
+
}
|
|
105
|
+
return new Response(null, { status: 302, headers });
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createHandoffStartHandler(config: HandoffConfig) {
|
|
110
|
+
return async function GET(request: Request) {
|
|
111
|
+
const returnTo = await validReturnTo(request);
|
|
112
|
+
if (!returnTo) {
|
|
113
|
+
return new Response("Invalid returnTo", { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prompt = new URL(request.url).searchParams.get("prompt");
|
|
117
|
+
const token = await centralToken(request, config.nextAuthSecret);
|
|
118
|
+
if (token?.refreshToken && !prompt) {
|
|
119
|
+
return completeRedirect(token, returnTo, config.nextAuthSecret);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const origin = requestOrigin(request);
|
|
123
|
+
const finish = `${origin}/handoff/finish?returnTo=${encodeURIComponent(returnTo)}`;
|
|
124
|
+
const promptParam = prompt ? `&prompt=${encodeURIComponent(prompt)}` : "";
|
|
125
|
+
const login = `${origin}/auth/login?callbackUrl=${encodeURIComponent(finish)}${promptParam}`;
|
|
126
|
+
return Response.redirect(login, 302);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createHandoffFinishHandler(config: HandoffConfig) {
|
|
131
|
+
return async function GET(request: Request) {
|
|
132
|
+
const returnTo = await validReturnTo(request);
|
|
133
|
+
if (!returnTo) {
|
|
134
|
+
return new Response("Invalid returnTo", { status: 400 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const token = await centralToken(request, config.nextAuthSecret);
|
|
138
|
+
if (!token?.refreshToken) {
|
|
139
|
+
const origin = requestOrigin(request);
|
|
140
|
+
const start = `${origin}/handoff/start?returnTo=${encodeURIComponent(returnTo)}`;
|
|
141
|
+
return Response.redirect(start, 302);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return completeRedirect(token, returnTo, config.nextAuthSecret);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isKnownTenantHost } from "@snokam/config";
|
|
2
|
+
import { getManagedBearer } from "@snokam/identity";
|
|
3
|
+
|
|
4
|
+
interface ReturnToOptions {
|
|
5
|
+
platformApiBase: string;
|
|
6
|
+
platformBaseDomain: string;
|
|
7
|
+
allowLocalhost?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function isAllowedReturnTo(
|
|
11
|
+
returnTo: string,
|
|
12
|
+
opts: ReturnToOptions
|
|
13
|
+
): Promise<boolean> {
|
|
14
|
+
let url: URL;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(returnTo);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const host = url.hostname.toLowerCase();
|
|
22
|
+
if (host === "localhost" || host === "127.0.0.1") {
|
|
23
|
+
return !!opts.allowLocalhost && url.protocol === "http:";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (url.protocol !== "https:") {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const base = opts.platformBaseDomain.toLowerCase().replace(/^\./, "");
|
|
31
|
+
if (base && (host === base || host.endsWith(`.${base}`))) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return isKnownTenantHost({
|
|
36
|
+
host,
|
|
37
|
+
platformApiBase: opts.platformApiBase,
|
|
38
|
+
authToken: await getManagedBearer(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "crypto";
|
|
7
|
+
|
|
8
|
+
export interface HandoffClaims {
|
|
9
|
+
oid: string;
|
|
10
|
+
email: string;
|
|
11
|
+
name: string;
|
|
12
|
+
tid: string;
|
|
13
|
+
business: string;
|
|
14
|
+
refreshToken: string;
|
|
15
|
+
authMethod?: string;
|
|
16
|
+
image?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ALGORITHM = "aes-256-gcm";
|
|
20
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
21
|
+
|
|
22
|
+
function rejectHandoff(reason: string): null {
|
|
23
|
+
console.warn(`[handoff] ticket rejected: ${reason}`);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const keyFromSecret = (secret: string): Buffer => {
|
|
28
|
+
if (!secret)
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Hand-off secret (NEXTAUTH_SECRET) is empty; refusing to seal/open a forgeable ticket."
|
|
31
|
+
);
|
|
32
|
+
return createHash("sha256").update(secret).digest();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function sealHandoff(
|
|
36
|
+
claims: HandoffClaims,
|
|
37
|
+
secret: string,
|
|
38
|
+
ttlSeconds: number = DEFAULT_TTL_SECONDS
|
|
39
|
+
): string {
|
|
40
|
+
const payload = JSON.stringify({
|
|
41
|
+
...claims,
|
|
42
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
43
|
+
});
|
|
44
|
+
const iv = randomBytes(12);
|
|
45
|
+
const cipher = createCipheriv(ALGORITHM, keyFromSecret(secret), iv);
|
|
46
|
+
const ciphertext = Buffer.concat([
|
|
47
|
+
cipher.update(payload, "utf8"),
|
|
48
|
+
cipher.final(),
|
|
49
|
+
]);
|
|
50
|
+
return [iv, cipher.getAuthTag(), ciphertext]
|
|
51
|
+
.map((part) => part.toString("base64url"))
|
|
52
|
+
.join(".");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function openHandoff(
|
|
56
|
+
ticket: string,
|
|
57
|
+
secret: string,
|
|
58
|
+
expectedBusiness?: string,
|
|
59
|
+
now: number = Date.now()
|
|
60
|
+
): HandoffClaims | null {
|
|
61
|
+
const parts = ticket.split(".");
|
|
62
|
+
if (parts.length !== 3) {
|
|
63
|
+
return rejectHandoff("malformed ticket (expected iv.tag.ciphertext)");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const [iv, tag, ciphertext] = parts.map((part) =>
|
|
68
|
+
Buffer.from(part, "base64url")
|
|
69
|
+
);
|
|
70
|
+
const decipher = createDecipheriv(ALGORITHM, keyFromSecret(secret), iv);
|
|
71
|
+
decipher.setAuthTag(tag);
|
|
72
|
+
const plaintext = Buffer.concat([
|
|
73
|
+
decipher.update(ciphertext),
|
|
74
|
+
decipher.final(),
|
|
75
|
+
]).toString("utf8");
|
|
76
|
+
|
|
77
|
+
const payload = JSON.parse(plaintext) as HandoffClaims & { exp: number };
|
|
78
|
+
if (payload.exp * 1000 < now) {
|
|
79
|
+
return rejectHandoff(
|
|
80
|
+
`expired (exp ${payload.exp}, now ${Math.floor(now / 1000)})`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (expectedBusiness && payload.business !== expectedBusiness) {
|
|
85
|
+
return rejectHandoff(
|
|
86
|
+
`business mismatch (ticket "${payload.business}" vs host "${expectedBusiness}")`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { exp: _exp, ...claims } = payload;
|
|
91
|
+
return claims;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return rejectHandoff(
|
|
94
|
+
`decrypt failed (secret mismatch or tampered): ${(error as Error).message}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|