Merged in feature/curity-social-login (pull request #2963)
feat(SW-3541): Do social login after login to SAS * feat(auth): wip social login via curity * Setup social login auth flow * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/curity-social-login * Added support for getting scandic tokens and refresh them * feat: Enhance social login and session management with auto-refresh and improved error handling * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/curity-social-login * wrap layout in suspense * revert app/layout.tsx * fix import * cleanup * merge * merge * dont pass client_secret in the url to curity * add state validation when doing social login through /authorize * remove debug logging Approved-by: Anton Gunnarsson
This commit is contained in:
51
apps/partner-sas/app/api/web/auth/callback/curity/route.ts
Normal file
51
apps/partner-sas/app/api/web/auth/callback/curity/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import { getToken } from "@/auth/scandic/getToken"
|
||||
import { createSession } from "@/auth/scandic/session"
|
||||
|
||||
const logger = createLogger("curity-callback")
|
||||
export async function GET(req: NextRequest) {
|
||||
const code = req.nextUrl.searchParams.get("code")
|
||||
const state = req.nextUrl.searchParams.get("state")
|
||||
const savedState = req.cookies.get("oauth_state")?.value
|
||||
|
||||
if (!code || !state) {
|
||||
logger.error("Missing code or state", { url: req.nextUrl.toString() })
|
||||
throw new Error("Missing code or state, auth failed")
|
||||
}
|
||||
|
||||
if (!savedState) {
|
||||
logger.error("No saved state cookie", { url: req.nextUrl.toString() })
|
||||
throw new Error("Missing saved oauth state, auth failed")
|
||||
}
|
||||
|
||||
if (state !== savedState) {
|
||||
logger.error("State mismatch", {
|
||||
provided: state,
|
||||
saved: savedState,
|
||||
url: req.nextUrl.toString(),
|
||||
})
|
||||
throw new Error("Invalid state, possible CSRF")
|
||||
}
|
||||
|
||||
const tokenResponse = await getToken({
|
||||
code,
|
||||
})
|
||||
|
||||
await createSession({
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token,
|
||||
expires_in: tokenResponse.expires_in,
|
||||
})
|
||||
|
||||
const c = await cookies()
|
||||
c.delete({ name: "oauth_state", path: "/" })
|
||||
|
||||
const url = new URL(env.PUBLIC_URL)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
53
apps/partner-sas/app/api/web/auth/scandic/login/route.ts
Normal file
53
apps/partner-sas/app/api/web/auth/scandic/login/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import "server-only"
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
|
||||
import { cookies } from "next/headers"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
|
||||
import { config } from "@/auth/scandic/config"
|
||||
import { endpoints } from "@/auth/scandic/endpoints"
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const authUrl = new URL(endpoints.authorization_endpoint.toString())
|
||||
|
||||
const configData: Omit<typeof config, "client_secret"> = {
|
||||
client_id: config.client_id,
|
||||
redirect_uri: config.redirect_uri,
|
||||
acr_values: config.acr_values,
|
||||
scope: config.scope,
|
||||
response_type: config.response_type,
|
||||
issuer: config.issuer,
|
||||
}
|
||||
|
||||
Object.entries(configData).forEach(([key, value]) => {
|
||||
authUrl.searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
const state = generateState()
|
||||
const redirectTo = req.nextUrl.searchParams.get("redirect_to") || "/"
|
||||
authUrl.searchParams.set("redirect_to", redirectTo)
|
||||
authUrl.searchParams.set("state", state)
|
||||
|
||||
const c = await cookies()
|
||||
c.set({
|
||||
name: "oauth_state",
|
||||
value: state,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 300,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authUrl)
|
||||
}
|
||||
|
||||
function generateState(length = 32) {
|
||||
const seed = randomUUID()
|
||||
|
||||
const hashed = createHash("sha256")
|
||||
.update(seed + Math.random())
|
||||
.digest("hex")
|
||||
return hashed.slice(0, length)
|
||||
}
|
||||
12
apps/partner-sas/app/api/web/auth/scandic/logout/route.ts
Normal file
12
apps/partner-sas/app/api/web/auth/scandic/logout/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type NextRequest } from "next/server"
|
||||
|
||||
import { noContent } from "@/server/errors/next"
|
||||
|
||||
import { destroySession } from "@/auth/scandic/session"
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
await destroySession()
|
||||
// TODO: Should we call Scandic's logout endpoint?
|
||||
|
||||
return noContent()
|
||||
}
|
||||
122
apps/partner-sas/app/api/web/auth/scandic/refresh/route.ts
Normal file
122
apps/partner-sas/app/api/web/auth/scandic/refresh/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { type NextRequest } from "next/server"
|
||||
import { z } from "zod"
|
||||
|
||||
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import {
|
||||
badRequest,
|
||||
internalServerError,
|
||||
ok,
|
||||
response,
|
||||
} from "@/server/errors/next"
|
||||
|
||||
import { config } from "@/auth/scandic/config"
|
||||
import { endpoints } from "@/auth/scandic/endpoints"
|
||||
import {
|
||||
createSession,
|
||||
destroySession,
|
||||
getSession,
|
||||
} from "@/auth/scandic/session"
|
||||
|
||||
const logger = createLogger("scandic/refresh")
|
||||
export async function POST(_req: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
return badRequest("No session found")
|
||||
}
|
||||
|
||||
if (!session.refresh_token) {
|
||||
return badRequest("No refresh token found")
|
||||
}
|
||||
|
||||
const [newTokens, error] = await safeTry(
|
||||
refreshToken({ refreshToken: session.refresh_token })
|
||||
)
|
||||
|
||||
if (!newTokens || error) {
|
||||
console.error("Error refreshing token", error)
|
||||
|
||||
if (isResponseError(error)) {
|
||||
if (error.status === 400 && error.cause === "invalid_grant") {
|
||||
await destroySession()
|
||||
return badRequest("invalid_grant")
|
||||
}
|
||||
|
||||
return response({}, error.status, error.statusText)
|
||||
}
|
||||
|
||||
return internalServerError(error)
|
||||
}
|
||||
|
||||
logger.debug("Successfully refreshed, got new token(s)", {
|
||||
expires_in: newTokens.expires_in,
|
||||
got_new_refresh_token: newTokens.refresh_token !== session.refresh_token,
|
||||
got_new_access_token: newTokens.access_token !== session.access_token,
|
||||
})
|
||||
|
||||
await createSession({
|
||||
access_token: newTokens.access_token,
|
||||
refresh_token: newTokens.refresh_token ?? session.refresh_token,
|
||||
expires_in: newTokens.expires_in,
|
||||
})
|
||||
|
||||
return ok({ status: "refreshed token" })
|
||||
}
|
||||
|
||||
const refreshTokenResponse = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string().optional(),
|
||||
})
|
||||
|
||||
async function refreshToken({ refreshToken }: { refreshToken: string }) {
|
||||
const response = await fetch(endpoints.token_endpoint, {
|
||||
body: new URLSearchParams({
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
const new_tokens: Record<string, unknown> = await response.json()
|
||||
if (!response.ok) {
|
||||
throw (
|
||||
responseErrorSchema.safeParse({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
cause: new_tokens?.error,
|
||||
}).data ?? {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
cause: "unknown",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const tokenResult = refreshTokenResponse.parse(new_tokens)
|
||||
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
type ResponseError = z.infer<typeof responseErrorSchema>
|
||||
const responseErrorSchema = z.object({
|
||||
status: z.number(),
|
||||
statusText: z.string(),
|
||||
cause: z.enum(["invalid_grant", "unknown"]),
|
||||
})
|
||||
|
||||
function isResponseError(error: unknown): error is ResponseError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
"statusText" in error
|
||||
)
|
||||
}
|
||||
66
apps/partner-sas/app/api/web/auth/scandic/session/route.ts
Normal file
66
apps/partner-sas/app/api/web/auth/scandic/session/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
|
||||
|
||||
import { getSession } from "@/auth/scandic/session"
|
||||
|
||||
const logger = createLogger("scandic/session")
|
||||
|
||||
const socialSessionResponseSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("valid"),
|
||||
user: z.null(),
|
||||
expiresAt: z.string().datetime(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("expired"),
|
||||
expiresAt: z.string().datetime(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("error"),
|
||||
cause: z.string(),
|
||||
}),
|
||||
z.object({ status: z.literal("no_session"), user: z.null() }),
|
||||
])
|
||||
|
||||
export type SocialSessionResponse = z.infer<typeof socialSessionResponseSchema>
|
||||
|
||||
export async function GET(): Promise<NextResponse<SocialSessionResponse>> {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session || !session.access_token) {
|
||||
return createResponse({ status: "no_session", user: null })
|
||||
}
|
||||
|
||||
const hasExpired = dt(session.expires_at).isBefore(dt())
|
||||
if (hasExpired) {
|
||||
return createResponse({
|
||||
status: "expired",
|
||||
expiresAt: session.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
return createResponse({
|
||||
status: "valid",
|
||||
user: null,
|
||||
expiresAt: session.expires_at,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error("Failed to get session", error)
|
||||
return createResponse(
|
||||
{ status: "error", cause: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createResponse(
|
||||
data: SocialSessionResponse,
|
||||
options?: { status: number }
|
||||
) {
|
||||
return NextResponse.json(socialSessionResponseSchema.parse(data), {
|
||||
status: options?.status ?? 200,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user