Amplifyでアクセストークンをin-memoryに置いてみる
トークンの保存場所
古くはPlease Stop Using Local Storageにはじまり、Auth0のセキュリティガイダンスなどLocal Storageは危ない・やめとけ!という話は多数ありますが果たしてそうでしょうか?仕組みから考えれば、XSSにより任意のコードが実行できる状況であれば、フロントエンドでアクセストークンを扱う以上は奪取可能だと思いますし、実際にPoCを公開されてる方もいます。したがって、in-memoryだろうがLocal Storageだろうが本質的なリスクに大差はないと考えるべきでしょう。
ただこれだけをもって、Local Storage万歳と言うのは早計な気もします。ガチ勢には時間稼ぎ程度にしかならないかもしれませんが、攻撃者に「ちょっと味見してみたけどトークンなかったわ。。なんか面倒クセェ」と思わせることは無意味ではなく、ちょっとした抑止力になると思うのでSession Storageが採用できるようなケースであればin-memoryの適用を検討してみては?という話です。
#現実にはトークンをどこに置くかは、ページのリロードやブラウザ再起動などに伴うユーザビリティに関する要件によって選択肢が限られてしまうことも多いと思いますが。。
Auth0のSDKのアプローチ
in-memory推しのAuth0はSDKを提供していて、Webワーカーを使って結構凝ったことをやってます。あまり読み込めてないのですが、アプリはAPIを呼ぶ時にAuth0ClientのgetTokenSilentlyを呼び出すことで、in-memoryで管理してるInMemoryCacheから、もしくはWebワーカー経由でトークンエンドポインにアクセスしてトークンを取得できるようです。
Auth0はWebワーカーの採用理由について「アプリケーションとは異なるグローバルスコープでトークンの取得・保存が行える」と述べています。書かれていませんがアプリケーションのパフォーマンスなど他の面でも利点があるのかもしれません。また、キャッシュはin-memoryだけでなくLocal Storageで管理することも可能なようです。
今回は話が逸れるので触れませんが、Auth0とのセッションが残っている状態でのトークンの再取得をiframeを使って上手いことやっていてコチラも興味深いです。
const cacheLocationBuilders: Record<string, () => ICache> = {
memory: () => new InMemoryCache().enclosedCache,
localstorage: () => new LocalStorageCache()
};
export class InMemoryCache {
public enclosedCache: ICache = (function () {
let cache: Record<string, unknown> = {};
return {
set<T = Cacheable>(key: string, entry: T) {
cache[key] = entry;
},
get<T = Cacheable>(key: string): MaybePromise<T | undefined> {
const cacheEntry = cache[key] as T;
if (!cacheEntry) {
return;
}
return cacheEntry;
},
remove(key: string) {
delete cache[key];
},
allKeys(): string[] {
return Object.keys(cache);
}
};
})();
}
const authResult = await oauthToken(
{
baseUrl: this.domainUrl,
client_id: this.options.clientId,
auth0Client: this.options.auth0Client,
useFormData: this.options.useFormData,
timeout: this.httpTimeoutMs,
...options
},
this.worker
);
export async function oauthToken(
{
baseUrl,
timeout,
audience,
scope,
auth0Client,
useFormData,
...options
}: TokenEndpointOptions,
worker?: Worker
) {
const body = useFormData
? createQueryParams(options)
: JSON.stringify(options);
return await getJSON<TokenEndpointResponse>(
`${baseUrl}/oauth/token`,
timeout,
audience || 'default',
scope,
{
method: 'POST',
body,
headers: {
'Content-Type': useFormData
? 'application/x-www-form-urlencoded'
: 'application/json',
'Auth0-Client': btoa(
JSON.stringify(auth0Client || DEFAULT_AUTH0_CLIENT)
)
}
},
worker,
useFormData
);
}
Amplifyでもin-memoryにトークンを置いてみる
Amplifyでもin-memoryにトークンを置くくらいなら簡単です。デフォルトだとAmplifyはLocal Storageにトークンを保存しますが、初期化するときにStorageインタフェースを実装した独自のクラスを指定するだけでOK。
export class MyTokenStorage implements Storage {
private storage: { [key: string]: string | null } = {}
get length(): number {
return Object.keys(this.storage).length
}
key (index: number): string | null {
return Object.keys(this.storage)[index]
}
getItem(key: string): string | null {
return this.storage[key]
}
setItem(key: string, value: string): void {
this.storage[key] = value
}
removeItem(key: string): void {
delete this.storage[key]
}
clear(): void {
this.storage = {}
}
}
Amplify.configure({
Auth: {
storage: new MyTokenStorage(),
}
});
まとめ
現状Local Storage、Session Storage、in-memoryのどこに置いても、XSSによるトークン漏洩リスクに本質的な差はないと思いますが、in-memoryでトークンを管理するのは時間稼ぎや抑止力としては有効な手段だと思います。簡単な変更でやれるので、要件が許すならぜひお試しください。
言うまでもありませんが、フロントでトークンを扱う限り、セキュアコーディングはもちろんのこと、難読化, WAF, CSP, SRI, DAST, SCAなどを組み合わせてXSSを抑止していく必要があります。超重要なサービスを開発・運用してるなら、サーバサイドでセッション管理しましょう。
Discussion