Next.js×SSO(Cognito)
現在業務で Next.js に Congnito の SSO を乗っける作業をしているのですが。
Okta を使用した SSO の実装だったのですが、とくにサーバーサイドまで考慮した情報はほぼ皆無で、えらく苦戦しました。
ということで、今回は実装中にわかったことやまだわかっていないことを備忘録がてら書いていこうと思います。
Cognito と Okta の連携
この記事 通りに実装したらなんの問題もなかったです。
というかこの記事がなかったらおそらくつなぎこみまでいけませんでした…神記事。
Amplify(Cognito) と Next.js の連携
userPoolClientId の動的な反映
今回は普通にメールアドレスとパスワードでログインするケースと、SSO でログインするケースの 2 パターンを実装する必要がありまして。
そうなると必然的に userPoolClientId が 2 つに分かれると思います。
そこをサーバーサイドの段階で出し分けたい以上、いずれの userPoolClientId を使用するかの判定に使用する値は Cookie か DB に持たせるしかないかなと。
なので今回は以下のような感じで書いてみました、実装イメージは layout コンポーネントです。
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export type LayoutProps = {
children: ReactNode;
};
export default async function Layout({
children,
}: LayoutProps): Promise<JSX.Element> {
const loginProviderInCookie = cookies().get("login-provider");
if (!loginProviderInCookie) {
throw new Error("loginProviderInCookie is not exist");
}
const loginProvider = loginProviderInCookie.value;
if (loginProvider !== "DEFAULT" && loginProvider !== "SSO") {
throw new Error("loginProvider is not DEFAULT and SSO");
}
return loginProvider === "DEFAULT" ? (
<RootLayoutThatConfiguresAmplifyOnTheClient>
{authLayout}
</RootLayoutThatConfiguresAmplifyOnTheClient>
) : (
<RootLayoutThatConfiguresAmplifyOnTheClientWithSso>
{authLayout}
</RootLayoutThatConfiguresAmplifyOnTheClientWithSso>
);
}
RootLayoutThatConfiguresAmplifyOnTheClient
コンポーネントは Amplify の公式 に書かれているので、そこで各々初期化してやる感じですね。
SSO 時にセッション情報が Cookie に入らない
middleware や page コンポーネント のサーバーサイドでログイン状態の判定は以下のように行うことが多いと思うのですが。
const authenticated = await runWithAmplifyServerContext({
// 今回は middleware の場合
nextServerContext: { request, response },
operation: async (contextSpec) => {
try {
const session = await fetchAuthSession(contextSpec);
return (
typeof session.tokens?.accessToken !== "undefined" &&
typeof session.tokens?.idToken !== "undefined"
);
} catch {
console.log(error);
return false;
}
},
});
if (!authenticated) {
...
}
SSO でログインした場合、authenticated は常に false を返すみたいです。
どうやら Amplify の v6 でデグレした らしく、今のところ対処方法がありません。
どうしようもないため、今回はアクセストークンの有効期限内か否かの判定までサーバーサイドで行い、アクセストークンのリフレッシュはクライアントサイドで行うことにしました。
const {
payload: { exp },
} = decodeJWT(accessToken);
// 異常系
if (typeof exp !== "number") {
// ログイン画面へ遷移など
...
}
// 正常系
// access token が expired している場合
if (Date.now() >= exp * 1000) {
// クライアントサイドでアクセストークンのリフレッシュを行う
...
}
ちなみに fetchAuthSession を呼び出すことでアクセストークンがリフレッシュされることを初めて知りました、勉強不足…。
redirectSignOut は optional
Amplify の初期化時、SSO に対応する場合 redirectSignOut は必須の値だと思いますが。
リダイレクトさせたくない場合は空配列を渡してやれば良いみたいです、AWS 上では optional になっているのに、ややこしいですね。
import { Amplify } from "aws-amplify";
export default function configureAmplifyWithSso(): void {
Amplify.configure(
{
Auth: {
Cognito: {
loginWith: {
oauth: {
domain: "hogefuga",
redirectSignIn: ["http://hogefuga/redirect/sign-in"],
redirectSignOut: [],
responseType: "code",
scopes: ["openid", "aws.cognito.signin.user.admin"],
},
},
userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID_SSO!,
userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID!,
},
},
},
{ ssr: true },
);
}
わかっていないこと
SSO でログインした際に Cognito から返却される code の使い道
サインインに成功した際、リダイレクト URL に対して code と state というパラメーターが返却されるのですが。
てっきりこの code をデコード(?)してアクセストークンを取得するのかと思いきや、クライアントサイドでは普通に fetchAuthSession
からアクセストークンが取得できちゃいました。
じゃあこのパラメーターの使い道ってなんなんですかね?サーバーサイドだと使い道があるのか、まだよくわかっていません。
そんな感じです、まだまだ自身の知見の甘さを痛感させられますね。