@snokam/auth 2.2.22 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +360 -1027
  2. package/package.json +36 -48
  3. package/src/app-router/components/AuthComplete.styled.tsx +17 -0
  4. package/src/app-router/components/AuthComplete.tsx +52 -0
  5. package/src/app-router/components/Login.tsx +120 -0
  6. package/src/app-router/components/Provider/index.tsx +55 -0
  7. package/src/app-router/index.tsx +56 -0
  8. package/src/app-router/server.ts +93 -0
  9. package/src/build-proxy.tsx +160 -0
  10. package/src/components/Authenticate.tsx +38 -0
  11. package/src/components/Authenticated.tsx +11 -0
  12. package/src/components/Consent/index.tsx +35 -0
  13. package/src/components/EmployeeGate/index.tsx +59 -0
  14. package/src/components/Error/index.tsx +90 -0
  15. package/src/components/Error/styled.tsx +63 -0
  16. package/src/components/InactiveEmployee/index.tsx +18 -0
  17. package/src/components/LoggedOut/index.tsx +23 -0
  18. package/src/components/NotProvisioned/index.tsx +19 -0
  19. package/src/components/Unauthenticated.tsx +11 -0
  20. package/src/config.ts +9 -0
  21. package/src/handoff/handlers.ts +146 -0
  22. package/src/handoff/return-to.ts +40 -0
  23. package/src/handoff/ticket.ts +97 -0
  24. package/src/hooks.ts +146 -0
  25. package/src/impersonation/context.tsx +185 -0
  26. package/src/impersonation/cookies.ts +124 -0
  27. package/src/impersonation/handlers.ts +234 -0
  28. package/src/impersonation/routes.ts +30 -0
  29. package/src/impersonation/ticket.ts +79 -0
  30. package/src/index.tsx +31 -0
  31. package/src/options.tsx +389 -0
  32. package/src/page-router/components/Login.tsx +50 -0
  33. package/src/page-router/components/Provider/index.tsx +45 -0
  34. package/src/page-router/index.tsx +22 -0
  35. package/src/page-router/server.ts +6 -0
  36. package/src/password/api.ts +183 -0
  37. package/src/roles/context.tsx +54 -0
  38. package/src/route-handler/token.ts +124 -0
  39. package/src/route-handler.ts +52 -0
  40. package/src/scopes/context.tsx +127 -0
  41. package/src/scopes/matcher.ts +47 -0
  42. package/src/server/auth.tsx +30 -0
  43. package/src/server/handler.tsx +68 -0
  44. package/src/server/token.tsx +10 -0
  45. package/{lib/server.d.ts → src/server.tsx} +7 -4
  46. package/src/sso/exchange.ts +64 -0
  47. package/src/sso/tenant-cookie.ts +18 -0
  48. package/src/theme/employee-theme-sync.tsx +55 -0
  49. package/src/token/claims.ts +12 -0
  50. package/src/utils/callback-url.ts +17 -0
  51. package/src/utils/consent.ts +22 -0
  52. package/src/utils/cookies.ts +75 -0
  53. package/src/utils/cors.ts +68 -0
  54. package/src/utils/params.ts +15 -0
  55. package/src/utils/types.ts +52 -0
  56. package/src/utils/url.ts +19 -0
  57. package/lib/components/Authenticate.d.ts +0 -2
  58. package/lib/components/Authenticated.d.ts +0 -6
  59. package/lib/components/Error/index.d.ts +0 -5
  60. package/lib/components/Login.d.ts +0 -2
  61. package/lib/components/Provider/index.d.ts +0 -7
  62. package/lib/components/Unauthenticated.d.ts +0 -6
  63. package/lib/hooks.d.ts +0 -10
  64. package/lib/index.d.ts +0 -14
  65. package/lib/index.js +0 -42
  66. package/lib/index.js.map +0 -1
  67. package/lib/middleware.d.ts +0 -7
  68. package/lib/middleware.js +0 -2
  69. package/lib/middleware.js.map +0 -1
  70. package/lib/options.d.ts +0 -5
  71. package/lib/options.js +0 -2
  72. package/lib/options.js.map +0 -1
  73. package/lib/server/auth.d.ts +0 -11
  74. package/lib/server/handler.d.ts +0 -14
  75. package/lib/server/token.d.ts +0 -8
  76. package/lib/server.js +0 -2
  77. package/lib/server.js.map +0 -1
  78. package/lib/utils/cookies.d.ts +0 -5
  79. package/lib/utils/params.d.ts +0 -1
  80. 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
+ }